feat: strict MoySklad schema — реплика потерянного f7087e9
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 27s
CI / Web (React + Vite) (push) Successful in 23s
Docker Images / API image (push) Successful in 34s
Docker Images / Web image (push) Successful in 27s
Docker Images / Deploy stage (push) Successful in 15s

Main расходился с БД стейджа (Phase2c3_MsStrict в history, но код ещё ссылался на VatRate etc.) — деплой ломался. Реплицирую удаление сущностей вручную, чтобы код совпадал с таблицами.

Убрано (нет в MoySklad — не выдумываем):
- Domain: VatRate сущность целиком; Counterparty.Kind + enum CounterpartyKind; Store.Kind + enum StoreKind; Product.IsAlcohol; UnitOfMeasure.Symbol/DecimalPlaces/IsBase.
- EF: DbSet<VatRate>, ConfigureVatRate, Product.VatRate navigation, индекс Counterparty.Kind.
- DTO/Input: соответствующие поля и VatRateDto/Input.
- API: VatRatesController удалён; references в Products/Counterparties/Stores/UoM/Supplies/Retail/Stock.

Добавлено как в MoySklad:
- Product.Vat (int) + Product.VatEnabled — MoySklad держит НДС числом на товаре.
- KZ default VAT 16% — applied в сидерах и в MoySkladImportService когда товар не принёс свой vat.

MoySkladImportService:
- ResolveKind убран; CompanyType=entrepreneur→Individual (как и было).
- VatRates lookup → прямой p.Vat ?? 16 + p.Vat > 0 для VatEnabled.
- baseUnit ищется по code="796" вместо IsBase.

Web:
- types.ts: убраны CounterpartyKind/StoreKind/VatRate/Product.vatRateId/vatPercent/isAlcohol/UoM.symbol/decimalPlaces/isBase; добавлено Product.vat/vatEnabled; унифицировано unitSymbol→unitName.
- VatRatesPage удалён, роут из App.tsx тоже.
- CounterpartiesPage/StoresPage/UnitsOfMeasurePage: убраны соответствующие поля в формах.
- ProductEditPage: select "Ставка НДС" теперь с фиксированными 0/10/12/16/20 + чекбокс VatEnabled.
- Stock/RetailSale/Supply pages: unitSymbol → unitName.

deploy-stage unguarded — теперь код соответствует DB, авто-deploy безопасен.
This commit is contained in:
nurdotnet 2026-04-23 17:32:02 +05:00
parent 3fd2f8a223
commit 8fc9ef1a2e
35 changed files with 178 additions and 531 deletions

View file

@ -0,0 +1 @@
{"sessionId":"791848bb-ad06-4ccc-9c5a-13da4ee36524","pid":353363,"acquiredAt":1776883641374}

View file

@ -56,13 +56,6 @@ jobs:
name: Deploy stage
runs-on: [self-hosted, linux]
needs: [api, web]
# Temporary guard: main currently contains references to VatRate etc. while
# the stage DB is already on Phase2c3_MsStrict (vat_rates dropped). Until
# the user lands the matching code changes on main, an auto-deploy of a
# freshly built image from main would break the API. Run deploy only on
# tag/dispatch; normal pushes still build + push images to the local
# registry, but don't roll the stage forward.
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v4

View file

@ -20,14 +20,9 @@ public class CounterpartiesController : ControllerBase
[HttpGet]
public async Task<ActionResult<PagedResult<CounterpartyDto>>> List(
[FromQuery] PagedRequest req,
[FromQuery] CounterpartyKind? kind,
CancellationToken ct)
{
var q = _db.Counterparties.Include(c => c.Country).AsNoTracking().AsQueryable();
if (kind is not null)
{
q = q.Where(c => c.Kind == kind || c.Kind == CounterpartyKind.Both);
}
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
@ -43,7 +38,7 @@ public class CounterpartiesController : ControllerBase
.OrderBy(c => c.Name)
.Skip(req.Skip).Take(req.Take)
.Select(c => new CounterpartyDto(
c.Id, c.Name, c.LegalName, c.Kind, c.Type,
c.Id, c.Name, c.LegalName, c.Type,
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country != null ? c.Country.Name : null,
c.Address, c.Phone, c.Email,
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive))
@ -56,7 +51,7 @@ public async Task<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken
{
var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return c is null ? NotFound() : new CounterpartyDto(
c.Id, c.Name, c.LegalName, c.Kind, c.Type,
c.Id, c.Name, c.LegalName, c.Type,
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
c.Address, c.Phone, c.Email,
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive);
@ -95,7 +90,6 @@ private static Counterparty Apply(Counterparty e, CounterpartyInput i)
{
e.Name = i.Name;
e.LegalName = i.LegalName;
e.Kind = i.Kind;
e.Type = i.Type;
e.Bin = i.Bin;
e.Iin = i.Iin;
@ -117,7 +111,7 @@ private async Task<CounterpartyDto> ProjectAsync(Guid id, CancellationToken ct)
{
var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstAsync(x => x.Id == id, ct);
return new CounterpartyDto(
c.Id, c.Name, c.LegalName, c.Kind, c.Type,
c.Id, c.Name, c.LegalName, c.Type,
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
c.Address, c.Phone, c.Email,
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive);

View file

@ -111,7 +111,6 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
private IQueryable<Product> QueryIncludes() => _db.Products
.Include(p => p.UnitOfMeasure)
.Include(p => p.VatRate)
.Include(p => p.ProductGroup)
.Include(p => p.DefaultSupplier)
.Include(p => p.CountryOfOrigin)
@ -126,12 +125,12 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
private static readonly System.Linq.Expressions.Expression<Func<Product, ProductDto>> Projection = p =>
new ProductDto(
p.Id, p.Name, p.Article, p.Description,
p.UnitOfMeasureId, p.UnitOfMeasure!.Symbol,
p.VatRateId, p.VatRate!.Percent,
p.UnitOfMeasureId, p.UnitOfMeasure!.Name,
p.Vat, p.VatEnabled,
p.ProductGroupId, p.ProductGroup != null ? p.ProductGroup.Name : null,
p.DefaultSupplierId, p.DefaultSupplier != null ? p.DefaultSupplier.Name : null,
p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null,
p.IsService, p.IsWeighed, p.IsAlcohol, p.IsMarked,
p.IsService, p.IsWeighed, p.IsMarked,
p.MinStock, p.MaxStock,
p.PurchasePrice, p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
p.ImageUrl, p.IsActive,
@ -144,13 +143,13 @@ private static void Apply(Product e, ProductInput i)
e.Article = i.Article;
e.Description = i.Description;
e.UnitOfMeasureId = i.UnitOfMeasureId;
e.VatRateId = i.VatRateId;
e.Vat = i.Vat;
e.VatEnabled = i.VatEnabled;
e.ProductGroupId = i.ProductGroupId;
e.DefaultSupplierId = i.DefaultSupplierId;
e.CountryOfOriginId = i.CountryOfOriginId;
e.IsService = i.IsService;
e.IsWeighed = i.IsWeighed;
e.IsAlcohol = i.IsAlcohol;
e.IsMarked = i.IsMarked;
e.MinStock = i.MinStock;
e.MaxStock = i.MaxStock;

View file

@ -30,7 +30,7 @@ public async Task<ActionResult<PagedResult<StoreDto>>> List([FromQuery] PagedReq
var items = await q
.OrderByDescending(x => x.IsMain).ThenBy(x => x.Name)
.Skip(req.Skip).Take(req.Take)
.Select(x => new StoreDto(x.Id, x.Name, x.Code, x.Kind, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive))
.Select(x => new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive))
.ToListAsync(ct);
return new PagedResult<StoreDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
@ -39,7 +39,7 @@ public async Task<ActionResult<PagedResult<StoreDto>>> List([FromQuery] PagedReq
public async Task<ActionResult<StoreDto>> Get(Guid id, CancellationToken ct)
{
var x = await _db.Stores.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id, ct);
return x is null ? NotFound() : new StoreDto(x.Id, x.Name, x.Code, x.Kind, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive);
return x is null ? NotFound() : new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive);
}
[HttpPost, Authorize(Roles = "Admin,Manager")]
@ -51,13 +51,13 @@ public async Task<ActionResult<StoreDto>> Create([FromBody] StoreInput input, Ca
}
var e = new Store
{
Name = input.Name, Code = input.Code, Kind = input.Kind, Address = input.Address,
Name = input.Name, Code = input.Code,Address = input.Address,
Phone = input.Phone, ManagerName = input.ManagerName, IsMain = input.IsMain, IsActive = input.IsActive,
};
_db.Stores.Add(e);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id },
new StoreDto(e.Id, e.Name, e.Code, e.Kind, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive));
new StoreDto(e.Id, e.Name, e.Code, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive));
}
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
@ -71,7 +71,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] StoreInput input, Ca
}
e.Name = input.Name;
e.Code = input.Code;
e.Kind = input.Kind;
e.Address = input.Address;
e.Phone = input.Phone;
e.ManagerName = input.ManagerName;

View file

@ -24,13 +24,13 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(u => u.Name.ToLower().Contains(s) || u.Symbol.ToLower().Contains(s) || u.Code.ToLower().Contains(s));
q = q.Where(u => u.Name.ToLower().Contains(s) || u.Code.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
var items = await q
.OrderByDescending(u => u.IsBase).ThenBy(u => u.Name)
.OrderBy(u => u.Name)
.Skip(req.Skip).Take(req.Take)
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Symbol, u.Name, u.DecimalPlaces, u.IsBase, u.IsActive))
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.IsActive))
.ToListAsync(ct);
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
@ -39,30 +39,23 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct)
{
var u = await _db.UnitsOfMeasure.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Symbol, u.Name, u.DecimalPlaces, u.IsBase, u.IsActive);
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.IsActive);
}
[HttpPost, Authorize(Roles = "Admin,Manager")]
public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasureInput input, CancellationToken ct)
{
if (input.IsBase)
{
await _db.UnitsOfMeasure.Where(u => u.IsBase).ExecuteUpdateAsync(s => s.SetProperty(u => u.IsBase, false), ct);
}
var e = new UnitOfMeasure
{
Code = input.Code,
Symbol = input.Symbol,
Name = input.Name,
DecimalPlaces = input.DecimalPlaces,
IsBase = input.IsBase,
Description = input.Description,
IsActive = input.IsActive,
};
_db.UnitsOfMeasure.Add(e);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id },
new UnitOfMeasureDto(e.Id, e.Code, e.Symbol, e.Name, e.DecimalPlaces, e.IsBase, e.IsActive));
new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description, e.IsActive));
}
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
@ -71,16 +64,9 @@ public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput i
var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
if (input.IsBase && !e.IsBase)
{
await _db.UnitsOfMeasure.Where(u => u.IsBase && u.Id != id).ExecuteUpdateAsync(s => s.SetProperty(u => u.IsBase, false), ct);
}
e.Code = input.Code;
e.Symbol = input.Symbol;
e.Name = input.Name;
e.DecimalPlaces = input.DecimalPlaces;
e.IsBase = input.IsBase;
e.Description = input.Description;
e.IsActive = input.IsActive;
await _db.SaveChangesAsync(ct);
return NoContent();

View file

@ -1,101 +0,0 @@
using foodmarket.Application.Catalog;
using foodmarket.Application.Common;
using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Catalog;
[ApiController]
[Authorize]
[Route("api/catalog/vat-rates")]
public class VatRatesController : ControllerBase
{
private readonly AppDbContext _db;
public VatRatesController(AppDbContext db) => _db = db;
[HttpGet]
public async Task<ActionResult<PagedResult<VatRateDto>>> List([FromQuery] PagedRequest req, CancellationToken ct)
{
var q = _db.VatRates.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(v => v.Name.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
var items = await q
.OrderByDescending(v => v.IsDefault).ThenBy(v => v.Percent)
.Skip(req.Skip).Take(req.Take)
.Select(v => new VatRateDto(v.Id, v.Name, v.Percent, v.IsIncludedInPrice, v.IsDefault, v.IsActive))
.ToListAsync(ct);
return new PagedResult<VatRateDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<VatRateDto>> Get(Guid id, CancellationToken ct)
{
var v = await _db.VatRates.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return v is null ? NotFound() : new VatRateDto(v.Id, v.Name, v.Percent, v.IsIncludedInPrice, v.IsDefault, v.IsActive);
}
[HttpPost, Authorize(Roles = "Admin,Manager")]
public async Task<ActionResult<VatRateDto>> Create([FromBody] VatRateInput input, CancellationToken ct)
{
if (input.IsDefault)
{
await ResetDefaultsAsync(ct);
}
var e = new VatRate
{
Name = input.Name,
Percent = input.Percent,
IsIncludedInPrice = input.IsIncludedInPrice,
IsDefault = input.IsDefault,
IsActive = input.IsActive,
};
_db.VatRates.Add(e);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id },
new VatRateDto(e.Id, e.Name, e.Percent, e.IsIncludedInPrice, e.IsDefault, e.IsActive));
}
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
public async Task<IActionResult> Update(Guid id, [FromBody] VatRateInput input, CancellationToken ct)
{
var e = await _db.VatRates.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
if (input.IsDefault && !e.IsDefault)
{
await ResetDefaultsAsync(ct);
}
e.Name = input.Name;
e.Percent = input.Percent;
e.IsIncludedInPrice = input.IsIncludedInPrice;
e.IsDefault = input.IsDefault;
e.IsActive = input.IsActive;
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var e = await _db.VatRates.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
_db.VatRates.Remove(e);
await _db.SaveChangesAsync(ct);
return NoContent();
}
private async Task ResetDefaultsAsync(CancellationToken ct)
{
await _db.VatRates.Where(v => v.IsDefault).ExecuteUpdateAsync(s => s.SetProperty(v => v.IsDefault, false), ct);
}
}

View file

@ -50,7 +50,7 @@ public record StockRow(
.OrderBy(x => x.p.Name)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new StockRow(
x.p.Id, x.p.Name, x.p.Article, x.u.Symbol,
x.p.Id, x.p.Name, x.p.Article, x.u.Name,
x.st.Id, x.st.Name,
x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity))
.ToListAsync(ct);

View file

@ -276,7 +276,7 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
where l.SupplyId == id
orderby l.SortOrder
select new SupplyLineDto(
l.Id, l.ProductId, p.Name, p.Article, u.Symbol,
l.Id, l.ProductId, p.Name, p.Article, u.Name,
l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder))
.ToListAsync(ct);

View file

@ -352,7 +352,7 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
where l.RetailSaleId == id
orderby l.SortOrder
select new RetailSaleLineDto(
l.Id, l.ProductId, p.Name, p.Article, u.Symbol,
l.Id, l.ProductId, p.Name, p.Article, u.Name,
l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder))
.ToListAsync(ct);

View file

@ -32,21 +32,18 @@ public async Task StartAsync(CancellationToken ct)
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);
// KZ default VAT is 16% (applies as int on Product).
const int vatDefault = 16;
const int vat0 = 0;
var unitSht = await db.UnitsOfMeasure.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "шт", ct);
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "796", ct);
var unitKg = await db.UnitsOfMeasure.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "кг", ct);
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "166", ct);
var unitL = await db.UnitsOfMeasure.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == ", ct);
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "112", ct);
if (defaultVat is null || unitSht is null) return;
var vat = defaultVat.Id;
var vat0 = noVat?.Id ?? vat;
if (unitSht is null) return;
var retailPriceType = await db.PriceTypes.IgnoreQueryFilters()
.FirstOrDefaultAsync(p => p.OrganizationId == orgId && p.IsRetail, ct);
@ -88,7 +85,7 @@ Guid AddGroup(string name, Guid? parentId)
var supplier1 = new Counterparty
{
OrganizationId = orgId, Name = "ТОО «Продтрейд»", LegalName = "Товарищество с ограниченной ответственностью «Продтрейд»",
Kind = CounterpartyKind.Supplier, Type = CounterpartyType.LegalEntity,
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",
@ -97,7 +94,7 @@ Guid AddGroup(string name, Guid? parentId)
var supplier2 = new Counterparty
{
OrganizationId = orgId, Name = "ИП Иванов А.С.",
Kind = CounterpartyKind.Supplier, Type = CounterpartyType.Individual,
Type = CounterpartyType.Individual,
Iin = "850101300000", CountryId = kz?.Id,
Phone = "+7 (777) 100-00-02", ContactPerson = "Иванов Алексей",
IsActive = true,
@ -106,49 +103,49 @@ Guid AddGroup(string name, Guid? parentId)
// 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)[]
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, 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),
("Вода питьевая «Тассай» 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, 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),
("Молоко «Простоквашино» 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, 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),
("Хлеб «Семейный» нарезной 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, 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),
("Шоколад «Рахат» молочный 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, 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),
("Сахар-песок 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, 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),
("Чипсы «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 =>
@ -159,12 +156,12 @@ Guid AddGroup(string name, Guid? parentId)
Name = d.Name,
Article = d.Article,
UnitOfMeasureId = d.Unit,
VatRateId = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")
? vat0 : vat,
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,
IsWeighed = d.IsWeighed,
IsAlcohol = d.IsAlcohol,
IsActive = true,
PurchasePrice = Math.Round(d.RetailPrice * 0.72m, 2),
PurchaseCurrencyId = kzt.Id,

View file

@ -78,24 +78,15 @@ public async Task StartAsync(CancellationToken ct)
private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, CancellationToken ct)
{
var anyVat = await db.VatRates.IgnoreQueryFilters().AnyAsync(v => v.OrganizationId == orgId, ct);
if (!anyVat)
{
db.VatRates.AddRange(
new VatRate { OrganizationId = orgId, Name = "Без НДС", Percent = 0m, IsDefault = false },
new VatRate { OrganizationId = orgId, Name = "НДС 12%", Percent = 12m, IsIncludedInPrice = true, IsDefault = true }
);
}
var anyUnit = await db.UnitsOfMeasure.IgnoreQueryFilters().AnyAsync(u => u.OrganizationId == orgId, ct);
if (!anyUnit)
{
db.UnitsOfMeasure.AddRange(
new UnitOfMeasure { OrganizationId = orgId, Code = "796", Symbol = "шт", Name = "штука", DecimalPlaces = 0, IsBase = true },
new UnitOfMeasure { OrganizationId = orgId, Code = "166", Symbol = "кг", Name = "килограмм", DecimalPlaces = 3 },
new UnitOfMeasure { OrganizationId = orgId, Code = "112", Symbol = "л", Name = "литр", DecimalPlaces = 3 },
new UnitOfMeasure { OrganizationId = orgId, Code = "006", Symbol = "м", Name = "метр", DecimalPlaces = 3 },
new UnitOfMeasure { OrganizationId = orgId, Code = "625", Symbol = "уп", Name = "упаковка", DecimalPlaces = 0 }
new UnitOfMeasure { OrganizationId = orgId, Code = "796", Name = "штука" },
new UnitOfMeasure { OrganizationId = orgId, Code = "166", Name = "килограмм" },
new UnitOfMeasure { OrganizationId = orgId, Code = "112", Name = "литр" },
new UnitOfMeasure { OrganizationId = orgId, Code = "006", Name = "метр" },
new UnitOfMeasure { OrganizationId = orgId, Code = "625", Name = "упаковка" }
);
}
@ -116,7 +107,6 @@ private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
OrganizationId = orgId,
Name = "Основной склад",
Code = "MAIN",
Kind = StoreKind.Warehouse,
IsMain = true,
Address = "Алматы, ул. Пример 1",
};

View file

@ -6,17 +6,14 @@ public record CountryDto(Guid Id, string Code, string Name, int SortOrder);
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol, int MinorUnit, bool IsActive);
public record VatRateDto(
Guid Id, string Name, decimal Percent, bool IsIncludedInPrice, bool IsDefault, bool IsActive);
public record UnitOfMeasureDto(
Guid Id, string Code, string Symbol, string Name, int DecimalPlaces, bool IsBase, bool IsActive);
Guid Id, string Code, string Name, string? Description, bool IsActive);
public record PriceTypeDto(
Guid Id, string Name, bool IsDefault, bool IsRetail, int SortOrder, bool IsActive);
public record StoreDto(
Guid Id, string Name, string? Code, StoreKind Kind, string? Address, string? Phone,
Guid Id, string Name, string? Code, string? Address, string? Phone,
string? ManagerName, bool IsMain, bool IsActive);
public record RetailPointDto(
@ -27,7 +24,7 @@ public record ProductGroupDto(
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, bool IsActive);
public record CounterpartyDto(
Guid Id, string Name, string? LegalName, CounterpartyKind Kind, CounterpartyType Type,
Guid Id, string Name, string? LegalName, CounterpartyType Type,
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? CountryName,
string? Address, string? Phone, string? Email,
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive);
@ -38,12 +35,12 @@ public record ProductPriceDto(Guid Id, Guid PriceTypeId, string PriceTypeName, d
public record ProductDto(
Guid Id, string Name, string? Article, string? Description,
Guid UnitOfMeasureId, string UnitSymbol,
Guid VatRateId, decimal VatPercent,
Guid UnitOfMeasureId, string UnitName,
int Vat, bool VatEnabled,
Guid? ProductGroupId, string? ProductGroupName,
Guid? DefaultSupplierId, string? DefaultSupplierName,
Guid? CountryOfOriginId, string? CountryOfOriginName,
bool IsService, bool IsWeighed, bool IsAlcohol, bool IsMarked,
bool IsService, bool IsWeighed, bool IsMarked,
decimal? MinStock, decimal? MaxStock,
decimal? PurchasePrice, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
string? ImageUrl, bool IsActive,
@ -53,11 +50,10 @@ public record ProductDto(
// Upsert payloads (input)
public record CountryInput(string Code, string Name, int SortOrder = 0);
public record CurrencyInput(string Code, string Name, string Symbol, int MinorUnit = 2, bool IsActive = true);
public record VatRateInput(string Name, decimal Percent, bool IsIncludedInPrice, bool IsDefault = false, bool IsActive = true);
public record UnitOfMeasureInput(string Code, string Symbol, string Name, int DecimalPlaces, bool IsBase = false, bool IsActive = true);
public record UnitOfMeasureInput(string Code, string Name, string? Description = null, bool IsActive = true);
public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true);
public record StoreInput(
string Name, string? Code, StoreKind Kind = StoreKind.Warehouse,
string Name, string? Code,
string? Address = null, string? Phone = null, string? ManagerName = null,
bool IsMain = false, bool IsActive = true);
public record RetailPointInput(
@ -66,7 +62,7 @@ public record RetailPointInput(
string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true);
public record ProductGroupInput(string Name, Guid? ParentId, int SortOrder = 0, bool IsActive = true);
public record CounterpartyInput(
string Name, string? LegalName, CounterpartyKind Kind, CounterpartyType Type,
string Name, string? LegalName, CounterpartyType Type,
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId,
string? Address, string? Phone, string? Email,
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive = true);
@ -74,9 +70,9 @@ public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ea
public record ProductPriceInput(Guid PriceTypeId, decimal Amount, Guid CurrencyId);
public record ProductInput(
string Name, string? Article, string? Description,
Guid UnitOfMeasureId, Guid VatRateId,
Guid UnitOfMeasureId, int Vat, bool VatEnabled,
Guid? ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
bool IsService = false, bool IsWeighed = false, bool IsAlcohol = false, bool IsMarked = false,
bool IsService = false, bool IsWeighed = false, bool IsMarked = false,
decimal? MinStock = null, decimal? MaxStock = null,
decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null,
string? ImageUrl = null, bool IsActive = true,

View file

@ -6,7 +6,6 @@ public class Counterparty : TenantEntity
{
public string Name { get; set; } = null!; // отображаемое имя
public string? LegalName { get; set; } // полное юридическое имя
public CounterpartyKind Kind { get; set; } // Supplier / Customer / Both
public CounterpartyType Type { get; set; } // Юрлицо / Физлицо
public string? Bin { get; set; } // БИН (для юрлиц РК)
public string? Iin { get; set; } // ИИН (для физлиц РК)

View file

@ -1,28 +1,11 @@
namespace foodmarket.Domain.Catalog;
public enum CounterpartyKind
{
/// <summary>Не указано — дефолт для импортированных без явной классификации.
/// MoySklad сам не имеет встроенного поля Supplier/Customer, оно ставится
/// через теги или группы, и часто отсутствует. Не выдумываем за пользователя.</summary>
Unspecified = 0,
Supplier = 1,
Customer = 2,
Both = 3,
}
public enum CounterpartyType
{
LegalEntity = 1,
Individual = 2,
}
public enum StoreKind
{
Warehouse = 1,
RetailFloor = 2,
}
public enum BarcodeType
{
Ean13 = 1,

View file

@ -11,8 +11,10 @@ public class Product : TenantEntity
public Guid UnitOfMeasureId { get; set; }
public UnitOfMeasure? UnitOfMeasure { get; set; }
public Guid VatRateId { get; set; }
public VatRate? VatRate { get; set; }
// Ставка НДС на товаре — число 0/10/12/16/20 как в MoySklad.
// VatEnabled=true → НДС применяется, false → без НДС.
public int Vat { get; set; }
public bool VatEnabled { get; set; } = true;
public Guid? ProductGroupId { get; set; }
public ProductGroup? ProductGroup { get; set; }
@ -25,7 +27,6 @@ public class Product : TenantEntity
public bool IsService { get; set; } // услуга, а не физический товар
public bool IsWeighed { get; set; } // весовой (продаётся с весов)
public bool IsAlcohol { get; set; } // алкоголь (подакцизный)
public bool IsMarked { get; set; } // маркируемый (Datamatrix)
public decimal? MinStock { get; set; } // минимальный остаток (для уведомлений)

View file

@ -2,12 +2,12 @@
namespace foodmarket.Domain.Catalog;
// Склад: физическое место хранения товаров. Может быть чисто склад или торговый зал.
// Склад: физическое место хранения товаров. MoySklad не различает "склад" и
// "торговый зал" — это одна сущность entity/store, опираемся на это.
public class Store : TenantEntity
{
public string Name { get; set; } = null!;
public string? Code { get; set; } // внутренний код склада
public StoreKind Kind { get; set; } = StoreKind.Warehouse;
public string? Address { get; set; }
public string? Phone { get; set; }
public string? ManagerName { get; set; }

View file

@ -2,13 +2,11 @@
namespace foodmarket.Domain.Catalog;
// Tenant-scoped справочник единиц измерения.
// Единица измерения как в MoySklad entity/uom: code + name + description.
public class UnitOfMeasure : TenantEntity
{
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
public string Symbol { get; set; } = null!; // "шт", "кг", "л", "м"
public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
public int DecimalPlaces { get; set; } // 0 для шт, 3 для кг/л
public bool IsBase { get; set; } // базовая единица этой организации
public string? Description { get; set; }
public bool IsActive { get; set; } = true;
}

View file

@ -1,13 +0,0 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Catalog;
// Tenant-scoped: разные организации могут работать в разных режимах (с НДС / упрощёнка).
public class VatRate : TenantEntity
{
public string Name { get; set; } = null!; // "НДС 12%", "Без НДС"
public decimal Percent { get; set; } // 12.00, 0.00
public bool IsIncludedInPrice { get; set; } // входит ли в цену или начисляется сверху
public bool IsDefault { get; set; }
public bool IsActive { get; set; } = true;
}

View file

@ -39,14 +39,11 @@ public async Task<MoySkladImportResult> ImportCounterpartiesAsync(string token,
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще — это
// не наша выдумка, проверено через API: counterparty entity содержит только
// group (группа доступа), tags (произвольные), state (пользовательская цепочка
// статусов), companyType (legal/individual/entrepreneur). Никакой role/kind.
// Поэтому при импорте ВСЕГДА ставим Unspecified — пользователь сам решит.
// Параметр tags оставлен ради совместимости сигнатуры, не используется.
static foodmarket.Domain.Catalog.CounterpartyKind ResolveKind(IReadOnlyList<string>? tags)
=> foodmarket.Domain.Catalog.CounterpartyKind.Unspecified;
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще —
// counterparty entity содержит только group (группа доступа), tags
// (произвольные), state (пользовательская цепочка статусов), companyType
// (legal/individual/entrepreneur). Никакого role/kind. Поэтому у нас тоже
// этого поля нет — пусть пользователь сам решит.
static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType)
=> companyType switch
@ -83,7 +80,6 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
OrganizationId = orgId,
Name = Trim(c.Name, 255) ?? c.Name,
LegalName = Trim(c.LegalTitle, 500),
Kind = ResolveKind(c.Tags),
Type = ResolveType(c.CompanyType),
Bin = Trim(c.Inn, 20),
TaxNumber = Trim(c.Kpp, 20),
@ -121,11 +117,10 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
// Pre-load tenant defaults.
var defaultVat = await _db.VatRates.FirstOrDefaultAsync(v => v.IsDefault, ct)
?? await _db.VatRates.FirstAsync(ct);
var vatByPercent = await _db.VatRates.ToDictionaryAsync(v => (int)v.Percent, v => v.Id, ct);
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.IsBase, ct)
// Pre-load tenant defaults. KZ default VAT is 16% — applied when product didn't
// carry its own vat from MoySklad.
const int kzDefaultVat = 16;
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.Code == "796", ct)
?? await _db.UnitsOfMeasure.FirstAsync(ct);
var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct)
?? await _db.PriceTypes.FirstAsync(ct);
@ -194,7 +189,8 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
try
{
var vatId = p.Vat is int vp && vatByPercent.TryGetValue(vp, out var id) ? id : defaultVat.Id;
var vat = p.Vat ?? kzDefaultVat;
var vatEnabled = (p.Vat ?? kzDefaultVat) > 0;
Guid? groupId = p.ProductFolder?.Meta?.Href is { } href && TryExtractId(href) is { } msGroupId
&& localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : null;
Guid? countryId = p.Country?.Name is { } cn && countriesByName.TryGetValue(cn, out var cId) ? cId : null;
@ -209,11 +205,11 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
Article = Trim(article, 500),
Description = p.Description,
UnitOfMeasureId = baseUnit.Id,
VatRateId = vatId,
Vat = vat,
VatEnabled = vatEnabled,
ProductGroupId = groupId,
CountryOfOriginId = countryId,
IsWeighed = p.Weighed,
IsAlcohol = p.Alcoholic is not null,
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
IsActive = !p.Archived,
PurchasePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m,

View file

@ -26,7 +26,6 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<Country> Countries => Set<Country>();
public DbSet<Currency> Currencies => Set<Currency>();
public DbSet<VatRate> VatRates => Set<VatRate>();
public DbSet<UnitOfMeasure> UnitsOfMeasure => Set<UnitOfMeasure>();
public DbSet<Counterparty> Counterparties => Set<Counterparty>();
public DbSet<Store> Stores => Set<Store>();

View file

@ -10,7 +10,6 @@ public static void ConfigureCatalog(this ModelBuilder b)
{
b.Entity<Country>(ConfigureCountry);
b.Entity<Currency>(ConfigureCurrency);
b.Entity<VatRate>(ConfigureVatRate);
b.Entity<UnitOfMeasure>(ConfigureUnit);
b.Entity<Counterparty>(ConfigureCounterparty);
b.Entity<Store>(ConfigureStore);
@ -40,20 +39,12 @@ private static void ConfigureCurrency(EntityTypeBuilder<Currency> b)
b.HasIndex(x => x.Code).IsUnique();
}
private static void ConfigureVatRate(EntityTypeBuilder<VatRate> b)
{
b.ToTable("vat_rates");
b.Property(x => x.Name).HasMaxLength(100).IsRequired();
b.Property(x => x.Percent).HasPrecision(5, 2);
b.HasIndex(x => new { x.OrganizationId, x.Name }).IsUnique();
}
private static void ConfigureUnit(EntityTypeBuilder<UnitOfMeasure> b)
{
b.ToTable("units_of_measure");
b.Property(x => x.Code).HasMaxLength(10).IsRequired();
b.Property(x => x.Symbol).HasMaxLength(20).IsRequired();
b.Property(x => x.Name).HasMaxLength(100).IsRequired();
b.Property(x => x.Description).HasMaxLength(500);
b.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique();
}
@ -74,7 +65,6 @@ private static void ConfigureCounterparty(EntityTypeBuilder<Counterparty> b)
b.HasOne(x => x.Country).WithMany().HasForeignKey(x => x.CountryId).OnDelete(DeleteBehavior.Restrict);
b.HasIndex(x => new { x.OrganizationId, x.Name });
b.HasIndex(x => new { x.OrganizationId, x.Bin });
b.HasIndex(x => new { x.OrganizationId, x.Kind });
}
private static void ConfigureStore(EntityTypeBuilder<Store> b)
@ -130,7 +120,6 @@ private static void ConfigureProduct(EntityTypeBuilder<Product> b)
b.Property(x => x.ImageUrl).HasMaxLength(1000);
b.HasOne(x => x.UnitOfMeasure).WithMany().HasForeignKey(x => x.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.VatRate).WithMany().HasForeignKey(x => x.VatRateId).OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.ProductGroup).WithMany().HasForeignKey(x => x.ProductGroupId).OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.DefaultSupplier).WithMany().HasForeignKey(x => x.DefaultSupplierId).OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.CountryOfOrigin).WithMany().HasForeignKey(x => x.CountryOfOriginId).OnDelete(DeleteBehavior.Restrict);

View file

@ -4,7 +4,6 @@ import { LoginPage } from '@/pages/LoginPage'
import { DashboardPage } from '@/pages/DashboardPage'
import { CountriesPage } from '@/pages/CountriesPage'
import { CurrenciesPage } from '@/pages/CurrenciesPage'
import { VatRatesPage } from '@/pages/VatRatesPage'
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
import { PriceTypesPage } from '@/pages/PriceTypesPage'
import { StoresPage } from '@/pages/StoresPage'
@ -46,7 +45,6 @@ export default function App() {
<Route path="/catalog/products/:id" element={<ProductEditPage />} />
<Route path="/catalog/product-groups" element={<ProductGroupsPage />} />
<Route path="/catalog/units" element={<UnitsOfMeasurePage />} />
<Route path="/catalog/vat-rates" element={<VatRatesPage />} />
<Route path="/catalog/price-types" element={<PriceTypesPage />} />
<Route path="/catalog/counterparties" element={<CounterpartiesPage />} />
<Route path="/catalog/stores" element={<StoresPage />} />

View file

@ -71,7 +71,7 @@ export function ProductPicker({ open, onClose, onPick, title = 'Выбор то
<div className="text-xs text-slate-400 flex gap-2 font-mono">
{p.article && <span>{p.article}</span>}
{p.barcodes[0] && <span>· {p.barcodes[0].code}</span>}
<span>· {p.unitSymbol}</span>
<span>· {p.unitName}</span>
</div>
</div>
{p.purchasePrice !== null && (

View file

@ -6,25 +6,18 @@ export interface PagedResult<T> {
totalPages: number
}
export const CounterpartyKind = { Unspecified: 0, Supplier: 1, Customer: 2, Both: 3 } as const
export type CounterpartyKind = (typeof CounterpartyKind)[keyof typeof CounterpartyKind]
export const CounterpartyType = { LegalEntity: 1, Individual: 2 } as const
export type CounterpartyType = (typeof CounterpartyType)[keyof typeof CounterpartyType]
export const StoreKind = { Warehouse: 1, RetailFloor: 2 } as const
export type StoreKind = (typeof StoreKind)[keyof typeof StoreKind]
export const BarcodeType = { Ean13: 1, Ean8: 2, Code128: 3, Code39: 4, Upca: 5, Upce: 6, Other: 99 } as const
export type BarcodeType = (typeof BarcodeType)[keyof typeof BarcodeType]
export interface Country { id: string; code: string; name: string; sortOrder: number }
export interface Currency { id: string; code: string; name: string; symbol: string; minorUnit: number; isActive: boolean }
export interface VatRate { id: string; name: string; percent: number; isIncludedInPrice: boolean; isDefault: boolean; isActive: boolean }
export interface UnitOfMeasure { id: string; code: string; symbol: string; name: string; decimalPlaces: number; isBase: boolean; isActive: boolean }
export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; isActive: boolean }
export interface PriceType { id: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean }
export interface Store {
id: string; name: string; code: string | null; kind: StoreKind; address: string | null; phone: string | null;
id: string; name: string; code: string | null; address: string | null; phone: string | null;
managerName: string | null; isMain: boolean; isActive: boolean
}
export interface RetailPoint {
@ -33,7 +26,7 @@ export interface RetailPoint {
}
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; isActive: boolean }
export interface Counterparty {
id: string; name: string; legalName: string | null; kind: CounterpartyKind; type: CounterpartyType;
id: string; name: string; legalName: string | null; type: CounterpartyType;
bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null;
address: string | null; phone: string | null; email: string | null;
bankName: string | null; bankAccount: string | null; bik: string | null;
@ -43,12 +36,12 @@ export interface ProductBarcode { id: string; code: string; type: BarcodeType; i
export interface ProductPrice { id: string; priceTypeId: string; priceTypeName: string; amount: number; currencyId: string; currencyCode: string }
export interface Product {
id: string; name: string; article: string | null; description: string | null;
unitOfMeasureId: string; unitSymbol: string;
vatRateId: string; vatPercent: number;
unitOfMeasureId: string; unitName: string;
vat: number; vatEnabled: boolean;
productGroupId: string | null; productGroupName: string | null;
defaultSupplierId: string | null; defaultSupplierName: string | null;
countryOfOriginId: string | null; countryOfOriginName: string | null;
isService: boolean; isWeighed: boolean; isAlcohol: boolean; isMarked: boolean;
isService: boolean; isWeighed: boolean; isMarked: boolean;
minStock: number | null; maxStock: number | null;
purchasePrice: number | null; purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null;
imageUrl: string | null; isActive: boolean;
@ -56,7 +49,7 @@ export interface Product {
}
export interface StockRow {
productId: string; productName: string; article: string | null; unitSymbol: string;
productId: string; productName: string; article: string | null; unitName: string;
storeId: string; storeName: string;
quantity: number; reservedQuantity: number; available: number;
}
@ -83,7 +76,7 @@ export interface SupplyListRow {
export interface SupplyLineDto {
id: string | null; productId: string;
productName: string | null; productArticle: string | null; unitSymbol: string | null;
productName: string | null; productArticle: string | null; unitName: string | null;
quantity: number; unitPrice: number; lineTotal: number; sortOrder: number;
}
@ -116,7 +109,7 @@ export interface RetailSaleListRow {
export interface RetailSaleLineDto {
id: string | null; productId: string;
productName: string | null; productArticle: string | null; unitSymbol: string | null;
productName: string | null; productArticle: string | null; unitName: string | null;
quantity: number; unitPrice: number; discount: number; lineTotal: number;
vatPercent: number; sortOrder: number;
}

View file

@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
import type {
PagedResult, UnitOfMeasure, VatRate, ProductGroup, Counterparty,
PagedResult, UnitOfMeasure, ProductGroup, Counterparty,
Country, Currency, Store, PriceType,
} from '@/lib/types'
@ -14,7 +14,6 @@ function useLookup<T>(key: string, url: string) {
}
export const useUnits = () => useLookup<UnitOfMeasure>('units', '/api/catalog/units-of-measure')
export const useVatRates = () => useLookup<VatRate>('vat', '/api/catalog/vat-rates')
export const useProductGroups = () => useLookup<ProductGroup>('groups', '/api/catalog/product-groups')
export const useCountries = () => useLookup<Country>('countries', '/api/catalog/countries')
export const useCurrencies = () => useLookup<Currency>('currencies', '/api/catalog/currencies')

View file

@ -10,7 +10,7 @@ import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { type Counterparty, type Country, type PagedResult, CounterpartyKind, CounterpartyType } from '@/lib/types'
import { type Counterparty, type Country, type PagedResult, CounterpartyType } from '@/lib/types'
const URL = '/api/catalog/counterparties'
@ -18,7 +18,6 @@ interface Form {
id?: string
name: string
legalName: string
kind: CounterpartyKind
type: CounterpartyType
bin: string
iin: string
@ -36,20 +35,16 @@ interface Form {
}
const blankForm: Form = {
// Kind по умолчанию Unspecified — MoySklad не имеет такого поля у контрагентов,
// не выдумываем за пользователя. Пусть выберет вручную если нужно.
name: '', legalName: '', kind: CounterpartyKind.Unspecified, type: CounterpartyType.LegalEntity,
name: '', legalName: '', type: CounterpartyType.LegalEntity,
bin: '', iin: '', taxNumber: '', countryId: '',
address: '', phone: '', email: '',
bankName: '', bankAccount: '', bik: '',
contactPerson: '', notes: '', isActive: true,
}
const kindLabel: Record<CounterpartyKind, string> = {
[CounterpartyKind.Unspecified]: '—',
[CounterpartyKind.Supplier]: 'Поставщик',
[CounterpartyKind.Customer]: 'Покупатель',
[CounterpartyKind.Both]: 'Поставщик + Покупатель',
const typeLabel: Record<CounterpartyType, string> = {
[CounterpartyType.LegalEntity]: 'Юрлицо',
[CounterpartyType.Individual]: 'Физлицо',
}
export function CounterpartiesPage() {
@ -92,7 +87,7 @@ export function CounterpartiesPage() {
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, name: r.name, legalName: r.legalName ?? '', kind: r.kind, type: r.type,
id: r.id, name: r.name, legalName: r.legalName ?? '', type: r.type,
bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '',
countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '',
bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '',
@ -100,7 +95,7 @@ export function CounterpartiesPage() {
})}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: 'Тип', width: '120px', cell: (r) => kindLabel[r.kind] },
{ header: 'Тип', width: '120px', cell: (r) => typeLabel[r.type] },
{ header: 'БИН/ИИН', width: '140px', cell: (r) => <span className="font-mono">{r.bin ?? r.iin ?? '—'}</span> },
{ header: 'Телефон', width: '160px', cell: (r) => r.phone ?? '—' },
{ header: 'Страна', width: '120px', cell: (r) => r.countryName ?? '—' },
@ -139,14 +134,6 @@ export function CounterpartiesPage() {
<Field label="Юридическое название" className="col-span-2">
<TextInput value={form.legalName} onChange={(e) => setForm({ ...form, legalName: e.target.value })} />
</Field>
<Field label="Роль">
<Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as CounterpartyKind })}>
<option value={CounterpartyKind.Unspecified}>Не указано</option>
<option value={CounterpartyKind.Supplier}>Поставщик</option>
<option value={CounterpartyKind.Customer}>Покупатель</option>
<option value={CounterpartyKind.Both}>Поставщик + Покупатель</option>
</Select>
</Field>
<Field label="Тип лица">
<Select value={form.type} onChange={(e) => setForm({ ...form, type: Number(e.target.value) as CounterpartyType })}>
<option value={CounterpartyType.LegalEntity}>Юрлицо</option>

View file

@ -6,7 +6,7 @@ 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,
useUnits, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers,
} from '@/lib/useLookups'
import { BarcodeType, type Product } from '@/lib/types'
@ -18,13 +18,13 @@ interface Form {
article: string
description: string
unitOfMeasureId: string
vatRateId: string
vat: number
vatEnabled: boolean
productGroupId: string
defaultSupplierId: string
countryOfOriginId: string
isService: boolean
isWeighed: boolean
isAlcohol: boolean
isMarked: boolean
isActive: boolean
minStock: string
@ -36,11 +36,15 @@ interface Form {
barcodes: BarcodeRow[]
}
// KZ default VAT rate.
const defaultVat = 16
const vatChoices = [0, 10, 12, 16, 20]
const emptyForm: Form = {
name: '', article: '', description: '',
unitOfMeasureId: '', vatRateId: '',
unitOfMeasureId: '', vat: defaultVat, vatEnabled: true,
productGroupId: '', defaultSupplierId: '', countryOfOriginId: '',
isService: false, isWeighed: false, isAlcohol: false, isMarked: false, isActive: true,
isService: false, isWeighed: false, isMarked: false, isActive: true,
minStock: '', maxStock: '',
purchasePrice: '', purchaseCurrencyId: '',
imageUrl: '',
@ -55,7 +59,6 @@ export function ProductEditPage() {
const qc = useQueryClient()
const units = useUnits()
const vats = useVatRates()
const groups = useProductGroups()
const countries = useCountries()
const currencies = useCurrencies()
@ -76,10 +79,10 @@ export function ProductEditPage() {
const p = existing.data
setForm({
name: p.name, article: p.article ?? '', description: p.description ?? '',
unitOfMeasureId: p.unitOfMeasureId, vatRateId: p.vatRateId,
unitOfMeasureId: p.unitOfMeasureId, vat: p.vat, vatEnabled: p.vatEnabled,
productGroupId: p.productGroupId ?? '', defaultSupplierId: p.defaultSupplierId ?? '',
countryOfOriginId: p.countryOfOriginId ?? '',
isService: p.isService, isWeighed: p.isWeighed, isAlcohol: p.isAlcohol, isMarked: p.isMarked,
isService: p.isService, isWeighed: p.isWeighed, isMarked: p.isMarked,
isActive: p.isActive,
minStock: p.minStock?.toString() ?? '',
maxStock: p.maxStock?.toString() ?? '',
@ -93,16 +96,13 @@ export function ProductEditPage() {
}, [isNew, existing.data])
useEffect(() => {
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 ?? '' }))
setForm((f) => ({ ...f, unitOfMeasureId: 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])
}, [isNew, units.data, currencies.data, form.unitOfMeasureId, form.purchaseCurrencyId])
const save = useMutation({
mutationFn: async () => {
@ -111,13 +111,13 @@ export function ProductEditPage() {
article: form.article || null,
description: form.description || null,
unitOfMeasureId: form.unitOfMeasureId,
vatRateId: form.vatRateId,
vat: form.vat,
vatEnabled: form.vatEnabled,
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),
@ -168,7 +168,7 @@ export function ProductEditPage() {
const updateBarcode = (i: number, patch: Partial<BarcodeRow>) =>
setForm({ ...form, barcodes: form.barcodes.map((b, ix) => ix === i ? { ...b, ...patch } : b) })
const canSave = form.name.trim().length > 0 && !!form.unitOfMeasureId && !!form.vatRateId
const canSave = form.name.trim().length > 0 && !!form.unitOfMeasureId
return (
<form onSubmit={onSubmit} className="flex flex-col h-full">
@ -234,13 +234,12 @@ export function ProductEditPage() {
<Field label="Единица измерения *">
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
<option value=""></option>
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.symbol} {u.name}</option>)}
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</Select>
</Field>
<Field label="Ставка НДС *">
<Select required value={form.vatRateId} onChange={(e) => setForm({ ...form, vatRateId: e.target.value })}>
<option value=""></option>
{vats.data?.map((v) => <option key={v.id} value={v.id}>{v.name} ({v.percent}%)</option>)}
<Field label="Ставка НДС, %">
<Select value={form.vat} onChange={(e) => setForm({ ...form, vat: Number(e.target.value) })}>
{vatChoices.map((v) => <option key={v} value={v}>{v}%</option>)}
</Select>
</Field>
<Field label="Группа">
@ -266,9 +265,9 @@ export function ProductEditPage() {
</Field>
</Grid>
<div className="pt-3 mt-3 border-t border-slate-100 dark:border-slate-800 flex flex-wrap gap-x-6 gap-y-2">
<Checkbox label="НДС применяется" checked={form.vatEnabled} onChange={(v) => setForm({ ...form, vatEnabled: v })} />
<Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} />
<Checkbox label="Весовой" checked={form.isWeighed} onChange={(v) => setForm({ ...form, isWeighed: v })} />
<Checkbox label="Алкоголь" checked={form.isAlcohol} onChange={(v) => setForm({ ...form, isAlcohol: v })} />
<Checkbox label="Маркируемый" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
<Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>

View file

@ -45,13 +45,12 @@ export function ProductsPage() {
</div>
)},
{ header: 'Группа', width: '200px', cell: (r) => r.productGroupName ?? '—' },
{ header: 'Ед.', width: '70px', cell: (r) => r.unitSymbol },
{ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vatPercent}%` },
{ header: 'Ед.', width: '70px', cell: (r) => r.unitName },
{ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vat}%` },
{ header: 'Тип', width: '140px', cell: (r) => (
<div className="flex gap-1 flex-wrap">
{r.isService && <span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-700">Услуга</span>}
{r.isWeighed && <span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Весовой</span>}
{r.isAlcohol && <span className="text-xs px-1.5 py-0.5 rounded bg-red-50 text-red-700">Алкоголь</span>}
{r.isMarked && <span className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">Маркир.</span>}
</div>
)},

View file

@ -14,7 +14,7 @@ interface LineRow {
productId: string
productName: string
productArticle: string | null
unitSymbol: string | null
unitName: string | null
quantity: number
unitPrice: number
discount: number
@ -81,7 +81,7 @@ export function RetailSaleEditPage() {
productId: l.productId,
productName: l.productName ?? '',
productArticle: l.productArticle,
unitSymbol: l.unitSymbol,
unitName: l.unitName,
quantity: l.quantity, unitPrice: l.unitPrice, discount: l.discount,
vatPercent: l.vatPercent,
})),
@ -179,11 +179,11 @@ export function RetailSaleEditPage() {
productId: p.id,
productName: p.name,
productArticle: p.article,
unitSymbol: p.unitSymbol,
unitName: p.unitName,
quantity: 1,
unitPrice: retail?.amount ?? 0,
discount: 0,
vatPercent: p.vatPercent,
vatPercent: p.vat * 1,
}],
})
}
@ -323,7 +323,7 @@ export function RetailSaleEditPage() {
<div className="font-medium">{l.productName}</div>
{l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>}
</td>
<td className="py-2 px-3 text-slate-500">{l.unitSymbol}</td>
<td className="py-2 px-3 text-slate-500">{l.unitName}</td>
<td className="py-2 px-3">
<TextInput type="number" step="0.001" disabled={isPosted}
className="text-right font-mono" value={l.quantity}

View file

@ -60,7 +60,7 @@ export function StockPage() {
</div>
)},
{ header: 'Склад', width: '220px', cell: (r) => r.storeName },
{ header: 'Ед.', width: '80px', cell: (r) => r.unitSymbol },
{ header: 'Ед.', width: '80px', cell: (r) => r.unitName },
{ header: 'Остаток', width: '130px', className: 'text-right font-mono', cell: (r) => r.quantity.toLocaleString('ru', { maximumFractionDigits: 3 }) },
{ header: 'Резерв', width: '110px', className: 'text-right font-mono text-slate-400', cell: (r) => r.reservedQuantity ? r.reservedQuantity.toLocaleString('ru') : '—' },
{ header: 'Доступно', width: '130px', className: 'text-right font-mono font-semibold', cell: (r) => (

View file

@ -6,9 +6,9 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, Select, Checkbox } from '@/components/Field'
import { Field, TextInput, Checkbox } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { type Store, StoreKind } from '@/lib/types'
import { type Store } from '@/lib/types'
const URL = '/api/catalog/stores'
@ -16,7 +16,6 @@ interface Form {
id?: string
name: string
code: string
kind: StoreKind
address: string
phone: string
managerName: string
@ -25,7 +24,7 @@ interface Form {
}
const blankForm: Form = {
name: '', code: '', kind: StoreKind.Warehouse, address: '', phone: '',
name: '', code: '', address: '', phone: '',
managerName: '', isMain: false, isActive: true,
}
@ -62,14 +61,13 @@ export function StoresPage() {
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, name: r.name, code: r.code ?? '', kind: r.kind,
id: r.id, name: r.name, code: r.code ?? '',
address: r.address ?? '', phone: r.phone ?? '', managerName: r.managerName ?? '',
isMain: r.isMain, isActive: r.isActive,
})}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: 'Код', width: '120px', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> },
{ header: 'Тип', width: '150px', cell: (r) => r.kind === StoreKind.Warehouse ? 'Склад' : 'Торговый зал' },
{ header: 'Адрес', cell: (r) => r.address ?? '—' },
{ header: 'Основной', width: '110px', cell: (r) => r.isMain ? '✓' : '—' },
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
@ -108,12 +106,6 @@ export function StoresPage() {
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
</Field>
</div>
<Field label="Тип">
<Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as StoreKind })}>
<option value={StoreKind.Warehouse}>Склад</option>
<option value={StoreKind.RetailFloor}>Торговый зал</option>
</Select>
</Field>
<Field label="Адрес">
<TextInput value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
</Field>

View file

@ -14,7 +14,7 @@ interface LineRow {
productId: string
productName: string
productArticle: string | null
unitSymbol: string | null
unitName: string | null
quantity: number
unitPrice: number
}
@ -76,7 +76,7 @@ export function SupplyEditPage() {
productId: l.productId,
productName: l.productName ?? '',
productArticle: l.productArticle,
unitSymbol: l.unitSymbol,
unitName: l.unitName,
quantity: l.quantity,
unitPrice: l.unitPrice,
})),
@ -169,7 +169,7 @@ export function SupplyEditPage() {
productId: p.id,
productName: p.name,
productArticle: p.article,
unitSymbol: p.unitSymbol,
unitName: p.unitName,
quantity: 1,
unitPrice: p.purchasePrice ?? 0,
}],
@ -304,7 +304,7 @@ export function SupplyEditPage() {
<div className="font-medium">{l.productName}</div>
{l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>}
</td>
<td className="py-2 px-3 text-slate-500">{l.unitSymbol}</td>
<td className="py-2 px-3 text-slate-500">{l.unitName}</td>
<td className="py-2 px-3">
<TextInput type="number" step="0.001" disabled={isPosted}
className="text-right font-mono"

View file

@ -15,14 +15,12 @@ const URL = '/api/catalog/units-of-measure'
interface Form {
id?: string
code: string
symbol: string
name: string
decimalPlaces: number
isBase: boolean
description: string
isActive: boolean
}
const blankForm: Form = { code: '', symbol: '', name: '', decimalPlaces: 0, isBase: false, isActive: true }
const blankForm: Form = { code: '', name: '', description: '', isActive: true }
export function UnitsOfMeasurePage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<UnitOfMeasure>(URL)
@ -41,7 +39,7 @@ export function UnitsOfMeasurePage() {
<>
<ListPageShell
title="Единицы измерения"
description="Код по ОКЕИ (шт=796, кг=166, л=112, м=006)."
description="Код по ОКЕИ (штука=796, килограмм=166, литр=112, метр=006)."
actions={
<>
<SearchBar value={search} onChange={setSearch} />
@ -56,13 +54,14 @@ export function UnitsOfMeasurePage() {
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({ ...r })}
onRowClick={(r) => setForm({
id: r.id, code: r.code, name: r.name,
description: r.description ?? '', isActive: r.isActive,
})}
columns={[
{ header: 'Код', width: '90px', cell: (r) => <span className="font-mono">{r.code}</span> },
{ header: 'Символ', width: '100px', cell: (r) => r.symbol },
{ header: 'Название', cell: (r) => r.name },
{ header: 'Дробность', width: '120px', className: 'text-right', cell: (r) => r.decimalPlaces },
{ header: 'Базовая', width: '100px', cell: (r) => r.isBase ? '✓' : '—' },
{ header: 'Описание', cell: (r) => r.description ?? '—' },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
@ -91,25 +90,15 @@ export function UnitsOfMeasurePage() {
>
{form && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Field label="Код ОКЕИ">
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
</Field>
<Field label="Символ">
<TextInput value={form.symbol} onChange={(e) => setForm({ ...form, symbol: e.target.value })} />
</Field>
</div>
<Field label="Название">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Количество знаков после запятой">
<TextInput
type="number" min="0" max="6"
value={form.decimalPlaces}
onChange={(e) => setForm({ ...form, decimalPlaces: Number(e.target.value) })}
/>
<Field label="Описание">
<TextInput value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field>
<Checkbox label="Базовая единица организации" checked={form.isBase} onChange={(v) => setForm({ ...form, isBase: v })} />
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
)}

View file

@ -1,116 +0,0 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, Checkbox } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { VatRate } from '@/lib/types'
const URL = '/api/catalog/vat-rates'
interface Form {
id?: string
name: string
percent: number
isIncludedInPrice: boolean
isDefault: boolean
isActive: boolean
}
const blankForm: Form = { name: '', percent: 0, isIncludedInPrice: true, isDefault: false, isActive: true }
export function VatRatesPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<VatRate>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null)
const save = async () => {
if (!form) return
const payload = {
name: form.name, percent: form.percent,
isIncludedInPrice: form.isIncludedInPrice, isDefault: form.isDefault, isActive: form.isActive,
}
if (form.id) await update.mutateAsync({ id: form.id, input: payload })
else await create.mutateAsync(payload)
setForm(null)
}
return (
<>
<ListPageShell
title="Ставки НДС"
description="Настройки ставок налога на добавленную стоимость."
actions={
<>
<SearchBar value={search} onChange={setSearch} />
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, name: r.name, percent: r.percent,
isIncludedInPrice: r.isIncludedInPrice, isDefault: r.isDefault, isActive: r.isActive,
})}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: '%', width: '90px', className: 'text-right font-mono', cell: (r) => r.percent.toFixed(2) },
{ header: 'В цене', width: '100px', cell: (r) => r.isIncludedInPrice ? '✓' : '—' },
{ header: 'По умолчанию', width: '140px', cell: (r) => r.isDefault ? '✓' : '—' },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
</ListPageShell>
<Modal
open={!!form}
onClose={() => setForm(null)}
title={form?.id ? 'Редактировать ставку НДС' : 'Новая ставка НДС'}
footer={
<>
{form?.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить ставку?')) {
await remove.mutateAsync(form.id!)
setForm(null)
}
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.name}>Сохранить</Button>
</>
}
>
{form && (
<div className="space-y-3">
<Field label="Название">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Процент">
<TextInput
type="number" step="0.01"
value={form.percent}
onChange={(e) => setForm({ ...form, percent: Number(e.target.value) })}
/>
</Field>
<Checkbox label="НДС включён в цену" checked={form.isIncludedInPrice} onChange={(v) => setForm({ ...form, isIncludedInPrice: v })} />
<Checkbox label="По умолчанию" checked={form.isDefault} onChange={(v) => setForm({ ...form, isDefault: v })} />
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
)}
</Modal>
</>
)
}