diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index b9c2358..fdfac4a 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -97,6 +97,7 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + builder.Services.AddHostedService(); var app = builder.Build(); diff --git a/src/food-market.api/Seed/DemoCatalogSeeder.cs b/src/food-market.api/Seed/DemoCatalogSeeder.cs new file mode 100644 index 0000000..e6fabdd --- /dev/null +++ b/src/food-market.api/Seed/DemoCatalogSeeder.cs @@ -0,0 +1,200 @@ +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(); + + 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; + + var defaultVat = await db.VatRates.IgnoreQueryFilters() + .FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.IsDefault, ct); + var noVat = await db.VatRates.IgnoreQueryFilters() + .FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.Percent == 0m, ct); + + var unitSht = await db.UnitsOfMeasure.IgnoreQueryFilters() + .FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "шт", ct); + var unitKg = await db.UnitsOfMeasure.IgnoreQueryFilters() + .FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "кг", ct); + var unitL = await db.UnitsOfMeasure.IgnoreQueryFilters() + .FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "л", ct); + + if (defaultVat is null || unitSht is null) return; + var vat = defaultVat.Id; + var vat0 = noVat?.Id ?? vat; + + 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(); + 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 = "Товарищество с ограниченной ответственностью «Продтрейд»", + Kind = CounterpartyKind.Supplier, 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 = "ИП Иванов А.С.", + Kind = CounterpartyKind.Supplier, 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, bool IsAlcohol)[] + { + // Напитки — безалкогольные + ("Вода питьевая «Тассай» 0.5л", gDrinksNon, 220, "DR-WAT-001", "2000000000018", kz?.Id, false, unitSht.Id, false), + ("Вода питьевая «Тассай» 1.5л", gDrinksNon, 380, "DR-WAT-002", "2000000000025", kz?.Id, false, unitSht.Id, false), + ("Сок «Да-Да» яблоко 1л", gDrinksNon, 650, "DR-JUC-001", "2000000000032", kz?.Id, false, unitSht.Id, false), + ("Сок «Да-Да» апельсин 1л", gDrinksNon, 650, "DR-JUC-002", "2000000000049", kz?.Id, false, unitSht.Id, false), + ("Кока-кола 0.5л", gDrinksNon, 420, "DR-SOD-001", "2000000000056", ru?.Id, false, unitSht.Id, false), + ("Пепси 0.5л", gDrinksNon, 420, "DR-SOD-002", "2000000000063", ru?.Id, false, unitSht.Id, false), + ("Спрайт 0.5л", gDrinksNon, 420, "DR-SOD-003", "2000000000070", ru?.Id, false, unitSht.Id, false), + ("Чай чёрный «Пиала» пакетированный, 100шт", gDrinksNon, 1250, "DR-TEA-001", "2000000000087", kz?.Id, false, unitSht.Id, false), + // Молочные + ("Молоко «Простоквашино» 2.5% 930мл", gDairy, 720, "DAI-MLK-001", "2000000000094", ru?.Id, false, unitSht.Id, false), + ("Молоко «Food Master» 3.2% 1л", gDairy, 620, "DAI-MLK-002", "2000000000100", kz?.Id, false, unitSht.Id, false), + ("Кефир «Food Master» 2.5% 1л", gDairy, 590, "DAI-KEF-001", "2000000000117", kz?.Id, false, unitSht.Id, false), + ("Йогурт «Актимель» клубника 100мл", gDairy, 180, "DAI-YOG-001", "2000000000124", null, false, unitSht.Id, false), + ("Творог «Простоквашино» 5% 200г", gDairy, 590, "DAI-CRD-001", "2000000000131", ru?.Id, false, unitSht.Id, false), + ("Сметана «Food Master» 20% 400г", gDairy, 850, "DAI-SCR-001", "2000000000148", kz?.Id, false, unitSht.Id, false), + ("Сыр «Российский» 45%", gDairy, 3200, "DAI-CHE-001", "2000000000155", kz?.Id, true, unitKg?.Id ?? unitSht.Id, false), + // Хлеб и выпечка + ("Хлеб «Семейный» нарезной 500г", gBakery, 180, "BAK-BRD-001", "2000000000162", kz?.Id, false, unitSht.Id, false), + ("Батон «Столичный» 400г", gBakery, 170, "BAK-BRD-002", "2000000000179", kz?.Id, false, unitSht.Id, false), + ("Лепёшка домашняя", gBakery, 220, "BAK-LEP-001", "2000000000186", kz?.Id, false, unitSht.Id, false), + ("Круассан шоколадный", gBakery, 320, "BAK-CRO-001", "2000000000193", kz?.Id, false, unitSht.Id, false), + // Кондитерские + ("Шоколад «Рахат» молочный 100г", gSweets, 650, "SW-CHO-001", "2000000000209", kz?.Id, false, unitSht.Id, false), + ("Шоколад «Алёнка» 100г", gSweets, 620, "SW-CHO-002", "2000000000216", ru?.Id, false, unitSht.Id, false), + ("Конфеты «Казахстанские» 200г", gSweets, 890, "SW-CND-001", "2000000000223", kz?.Id, false, unitSht.Id, false), + ("Печенье «Юбилейное» 250г", gSweets, 540, "SW-CKE-001", "2000000000230", ru?.Id, false, unitSht.Id, false), + ("Вафли «Артек» 250г", gSweets, 480, "SW-WAF-001", "2000000000247", kz?.Id, false, unitSht.Id, false), + // Бакалея + ("Сахар-песок 1кг", gGrocery, 480, "GRO-SUG-001", "2000000000254", kz?.Id, false, unitSht.Id, false), + ("Соль поваренная 1кг", gGrocery, 180, "GRO-SLT-001", "2000000000261", kz?.Id, false, unitSht.Id, false), + ("Рис круглозёрный 1кг", gGrocery, 850, "GRO-RCE-001", "2000000000278", kz?.Id, false, unitSht.Id, false), + ("Гречка «Мистраль» 900г", gGrocery, 990, "GRO-GRE-001", "2000000000285", ru?.Id, false, unitSht.Id, false), + ("Макароны «Corona» 500г", gGrocery, 420, "GRO-PAS-001", "2000000000292", kz?.Id, false, unitSht.Id, false), + ("Масло подсолнечное «Шедевр» 1л", gGrocery, 920, "GRO-OIL-001", "2000000000308", kz?.Id, false, unitL?.Id ?? unitSht.Id, false), + ("Кофе «Якобс Монарх» 95г растворимый", gGrocery, 2890, "GRO-COF-001", "2000000000315", null, false, unitSht.Id, false), + // Снеки + ("Чипсы «Lays» сметана-лук 80г", gSnacks, 380, "SN-CHP-001", "2000000000322", kz?.Id, false, unitSht.Id, false), + ("Сухарики «3 корочки» ржаные 40г", gSnacks, 190, "SN-SBR-001", "2000000000339", ru?.Id, false, unitSht.Id, false), + ("Орешки фисташки 100г", gSnacks, 1190, "SN-NUT-001", "2000000000346", kz?.Id, false, unitSht.Id, false), + ("Семечки «Мартин» 100г", gSnacks, 290, "SN-SED-001", "2000000000353", ru?.Id, false, unitSht.Id, false), + }; + + var products = demo.Select(d => + { + var p = new Product + { + OrganizationId = orgId, + Name = d.Name, + Article = d.Article, + UnitOfMeasureId = d.Unit, + VatRateId = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка") + ? vat0 : vat, + ProductGroupId = d.Group, + CountryOfOriginId = d.Country, + IsWeighed = d.IsWeighed, + IsAlcohol = d.IsAlcohol, + 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; +} diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index cb761d0..a4c2521 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -12,6 +12,7 @@ import { RetailPointsPage } from '@/pages/RetailPointsPage' import { ProductGroupsPage } from '@/pages/ProductGroupsPage' import { CounterpartiesPage } from '@/pages/CounterpartiesPage' import { ProductsPage } from '@/pages/ProductsPage' +import { ProductEditPage } from '@/pages/ProductEditPage' import { AppLayout } from '@/components/AppLayout' import { ProtectedRoute } from '@/components/ProtectedRoute' @@ -34,6 +35,8 @@ export default function App() { }> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/food-market.web/src/lib/useLookups.ts b/src/food-market.web/src/lib/useLookups.ts new file mode 100644 index 0000000..e978bbf --- /dev/null +++ b/src/food-market.web/src/lib/useLookups.ts @@ -0,0 +1,27 @@ +import { useQuery } from '@tanstack/react-query' +import { api } from '@/lib/api' +import type { + PagedResult, UnitOfMeasure, VatRate, ProductGroup, Counterparty, + Country, Currency, Store, PriceType, +} from '@/lib/types' + +function useLookup(key: string, url: string) { + return useQuery({ + queryKey: [`lookup:${key}`], + queryFn: async () => (await api.get>(`${url}?pageSize=500`)).data.items, + staleTime: 5 * 60 * 1000, + }) +} + +export const useUnits = () => useLookup('units', '/api/catalog/units-of-measure') +export const useVatRates = () => useLookup('vat', '/api/catalog/vat-rates') +export const useProductGroups = () => useLookup('groups', '/api/catalog/product-groups') +export const useCountries = () => useLookup('countries', '/api/catalog/countries') +export const useCurrencies = () => useLookup('currencies', '/api/catalog/currencies') +export const useStores = () => useLookup('stores', '/api/catalog/stores') +export const usePriceTypes = () => useLookup('price-types', '/api/catalog/price-types') +export const useSuppliers = () => useQuery({ + queryKey: ['lookup:suppliers'], + queryFn: async () => (await api.get>('/api/catalog/counterparties?pageSize=500&kind=1')).data.items, + staleTime: 5 * 60 * 1000, +}) diff --git a/src/food-market.web/src/pages/ProductEditPage.tsx b/src/food-market.web/src/pages/ProductEditPage.tsx new file mode 100644 index 0000000..041db69 --- /dev/null +++ b/src/food-market.web/src/pages/ProductEditPage.tsx @@ -0,0 +1,370 @@ +import { useState, useEffect, type FormEvent } from 'react' +import { useNavigate, useParams, Link } from 'react-router-dom' +import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' +import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react' +import { api } from '@/lib/api' +import { Button } from '@/components/Button' +import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field' +import { + useUnits, useVatRates, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers, +} from '@/lib/useLookups' +import { BarcodeType, type Product } from '@/lib/types' + +interface PriceRow { id?: string; priceTypeId: string; amount: number; currencyId: string } +interface BarcodeRow { id?: string; code: string; type: BarcodeType; isPrimary: boolean } + +interface Form { + name: string + article: string + description: string + unitOfMeasureId: string + vatRateId: string + productGroupId: string + defaultSupplierId: string + countryOfOriginId: string + isService: boolean + isWeighed: boolean + isAlcohol: boolean + isMarked: boolean + isActive: boolean + minStock: string + maxStock: string + purchasePrice: string + purchaseCurrencyId: string + imageUrl: string + prices: PriceRow[] + barcodes: BarcodeRow[] +} + +const emptyForm: Form = { + name: '', article: '', description: '', + unitOfMeasureId: '', vatRateId: '', + productGroupId: '', defaultSupplierId: '', countryOfOriginId: '', + isService: false, isWeighed: false, isAlcohol: false, isMarked: false, isActive: true, + minStock: '', maxStock: '', + purchasePrice: '', purchaseCurrencyId: '', + imageUrl: '', + prices: [], + barcodes: [], +} + +export function ProductEditPage() { + const { id } = useParams<{ id: string }>() + const isNew = !id || id === 'new' + const navigate = useNavigate() + const qc = useQueryClient() + + const units = useUnits() + const vats = useVatRates() + const groups = useProductGroups() + const countries = useCountries() + const currencies = useCurrencies() + const priceTypes = usePriceTypes() + const suppliers = useSuppliers() + + const existing = useQuery({ + queryKey: ['/api/catalog/products', id], + queryFn: async () => (await api.get(`/api/catalog/products/${id}`)).data, + enabled: !isNew, + }) + + const [form, setForm] = useState
(emptyForm) + const [error, setError] = useState(null) + + useEffect(() => { + if (!isNew && existing.data) { + const p = existing.data + setForm({ + name: p.name, article: p.article ?? '', description: p.description ?? '', + unitOfMeasureId: p.unitOfMeasureId, vatRateId: p.vatRateId, + productGroupId: p.productGroupId ?? '', defaultSupplierId: p.defaultSupplierId ?? '', + countryOfOriginId: p.countryOfOriginId ?? '', + isService: p.isService, isWeighed: p.isWeighed, isAlcohol: p.isAlcohol, isMarked: p.isMarked, + isActive: p.isActive, + minStock: p.minStock?.toString() ?? '', + maxStock: p.maxStock?.toString() ?? '', + purchasePrice: p.purchasePrice?.toString() ?? '', + purchaseCurrencyId: p.purchaseCurrencyId ?? '', + imageUrl: p.imageUrl ?? '', + prices: p.prices.map((x) => ({ id: x.id, priceTypeId: x.priceTypeId, amount: x.amount, currencyId: x.currencyId })), + barcodes: p.barcodes.map((x) => ({ id: x.id, code: x.code, type: x.type, isPrimary: x.isPrimary })), + }) + } + }, [isNew, existing.data]) + + useEffect(() => { + // Pre-fill defaults for new product + if (isNew && form.vatRateId === '' && vats.data?.length) { + setForm((f) => ({ ...f, vatRateId: vats.data?.find(v => v.isDefault)?.id ?? vats.data?.[0]?.id ?? '' })) + } + if (isNew && form.unitOfMeasureId === '' && units.data?.length) { + setForm((f) => ({ ...f, unitOfMeasureId: units.data?.find(u => u.isBase)?.id ?? units.data?.[0]?.id ?? '' })) + } + if (isNew && form.purchaseCurrencyId === '' && currencies.data?.length) { + setForm((f) => ({ ...f, purchaseCurrencyId: currencies.data?.find(c => c.code === 'KZT')?.id ?? currencies.data?.[0]?.id ?? '' })) + } + }, [isNew, vats.data, units.data, currencies.data, form.vatRateId, form.unitOfMeasureId, form.purchaseCurrencyId]) + + const save = useMutation({ + mutationFn: async () => { + const payload = { + name: form.name, + article: form.article || null, + description: form.description || null, + unitOfMeasureId: form.unitOfMeasureId, + vatRateId: form.vatRateId, + productGroupId: form.productGroupId || null, + defaultSupplierId: form.defaultSupplierId || null, + countryOfOriginId: form.countryOfOriginId || null, + isService: form.isService, + isWeighed: form.isWeighed, + isAlcohol: form.isAlcohol, + isMarked: form.isMarked, + isActive: form.isActive, + minStock: form.minStock === '' ? null : Number(form.minStock), + maxStock: form.maxStock === '' ? null : Number(form.maxStock), + purchasePrice: form.purchasePrice === '' ? null : Number(form.purchasePrice), + purchaseCurrencyId: form.purchaseCurrencyId || null, + imageUrl: form.imageUrl || null, + prices: form.prices.map((p) => ({ priceTypeId: p.priceTypeId, amount: Number(p.amount), currencyId: p.currencyId })), + barcodes: form.barcodes.map((b) => ({ code: b.code, type: b.type, isPrimary: b.isPrimary })), + } + if (isNew) { + return (await api.post('/api/catalog/products', payload)).data + } + await api.put(`/api/catalog/products/${id}`, payload) + return null + }, + onSuccess: (created) => { + qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }) + navigate(created ? `/catalog/products/${created.id}` : '/catalog/products') + }, + onError: (e: Error) => setError(e.message), + }) + + const remove = useMutation({ + mutationFn: async () => { await api.delete(`/api/catalog/products/${id}`) }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }) + navigate('/catalog/products') + }, + }) + + const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() } + + const addPrice = () => setForm({ ...form, prices: [...form.prices, { + priceTypeId: priceTypes.data?.find(p => !form.prices.some(x => x.priceTypeId === p.id))?.id ?? priceTypes.data?.[0]?.id ?? '', + amount: 0, + currencyId: currencies.data?.find(c => c.code === 'KZT')?.id ?? '', + }] }) + const removePrice = (i: number) => setForm({ ...form, prices: form.prices.filter((_, ix) => ix !== i) }) + const updatePrice = (i: number, patch: Partial) => + setForm({ ...form, prices: form.prices.map((p, ix) => ix === i ? { ...p, ...patch } : p) }) + + const addBarcode = () => setForm({ ...form, barcodes: [...form.barcodes, { + code: '', type: BarcodeType.Ean13, isPrimary: form.barcodes.length === 0, + }] }) + const removeBarcode = (i: number) => + setForm({ ...form, barcodes: form.barcodes.filter((_, ix) => ix !== i) }) + const updateBarcode = (i: number, patch: Partial) => + setForm({ ...form, barcodes: form.barcodes.map((b, ix) => ix === i ? { ...b, ...patch } : b) }) + + return ( + +
+
+ + + +
+

+ {isNew ? 'Новый товар' : form.name || 'Товар'} +

+

Справочник товаров и услуг

+
+
+
+ {!isNew && ( + + )} + +
+
+ + {error && ( +
{error}
+ )} + +
+
+
+ + setForm({ ...form, name: e.target.value })} /> + + + setForm({ ...form, article: e.target.value })} /> + +
+ +