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 name: Deploy stage
runs-on: [self-hosted, linux] runs-on: [self-hosted, linux]
needs: [api, web] 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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View file

@ -20,14 +20,9 @@ public class CounterpartiesController : ControllerBase
[HttpGet] [HttpGet]
public async Task<ActionResult<PagedResult<CounterpartyDto>>> List( public async Task<ActionResult<PagedResult<CounterpartyDto>>> List(
[FromQuery] PagedRequest req, [FromQuery] PagedRequest req,
[FromQuery] CounterpartyKind? kind,
CancellationToken ct) CancellationToken ct)
{ {
var q = _db.Counterparties.Include(c => c.Country).AsNoTracking().AsQueryable(); 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)) if (!string.IsNullOrWhiteSpace(req.Search))
{ {
var s = req.Search.Trim().ToLower(); var s = req.Search.Trim().ToLower();
@ -43,7 +38,7 @@ public class CounterpartiesController : ControllerBase
.OrderBy(c => c.Name) .OrderBy(c => c.Name)
.Skip(req.Skip).Take(req.Take) .Skip(req.Skip).Take(req.Take)
.Select(c => new CounterpartyDto( .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.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country != null ? c.Country.Name : null,
c.Address, c.Phone, c.Email, c.Address, c.Phone, c.Email,
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive)) 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); var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return c is null ? NotFound() : new CounterpartyDto( 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.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
c.Address, c.Phone, c.Email, c.Address, c.Phone, c.Email,
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive); 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.Name = i.Name;
e.LegalName = i.LegalName; e.LegalName = i.LegalName;
e.Kind = i.Kind;
e.Type = i.Type; e.Type = i.Type;
e.Bin = i.Bin; e.Bin = i.Bin;
e.Iin = i.Iin; 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); var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstAsync(x => x.Id == id, ct);
return new CounterpartyDto( 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.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
c.Address, c.Phone, c.Email, c.Address, c.Phone, c.Email,
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive); 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 private IQueryable<Product> QueryIncludes() => _db.Products
.Include(p => p.UnitOfMeasure) .Include(p => p.UnitOfMeasure)
.Include(p => p.VatRate)
.Include(p => p.ProductGroup) .Include(p => p.ProductGroup)
.Include(p => p.DefaultSupplier) .Include(p => p.DefaultSupplier)
.Include(p => p.CountryOfOrigin) .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 => private static readonly System.Linq.Expressions.Expression<Func<Product, ProductDto>> Projection = p =>
new ProductDto( new ProductDto(
p.Id, p.Name, p.Article, p.Description, p.Id, p.Name, p.Article, p.Description,
p.UnitOfMeasureId, p.UnitOfMeasure!.Symbol, p.UnitOfMeasureId, p.UnitOfMeasure!.Name,
p.VatRateId, p.VatRate!.Percent, p.Vat, p.VatEnabled,
p.ProductGroupId, p.ProductGroup != null ? p.ProductGroup.Name : null, p.ProductGroupId, p.ProductGroup != null ? p.ProductGroup.Name : null,
p.DefaultSupplierId, p.DefaultSupplier != null ? p.DefaultSupplier.Name : null, p.DefaultSupplierId, p.DefaultSupplier != null ? p.DefaultSupplier.Name : null,
p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.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.MinStock, p.MaxStock,
p.PurchasePrice, p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null, p.PurchasePrice, p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
p.ImageUrl, p.IsActive, p.ImageUrl, p.IsActive,
@ -144,13 +143,13 @@ private static void Apply(Product e, ProductInput i)
e.Article = i.Article; e.Article = i.Article;
e.Description = i.Description; e.Description = i.Description;
e.UnitOfMeasureId = i.UnitOfMeasureId; e.UnitOfMeasureId = i.UnitOfMeasureId;
e.VatRateId = i.VatRateId; e.Vat = i.Vat;
e.VatEnabled = i.VatEnabled;
e.ProductGroupId = i.ProductGroupId; e.ProductGroupId = i.ProductGroupId;
e.DefaultSupplierId = i.DefaultSupplierId; e.DefaultSupplierId = i.DefaultSupplierId;
e.CountryOfOriginId = i.CountryOfOriginId; e.CountryOfOriginId = i.CountryOfOriginId;
e.IsService = i.IsService; e.IsService = i.IsService;
e.IsWeighed = i.IsWeighed; e.IsWeighed = i.IsWeighed;
e.IsAlcohol = i.IsAlcohol;
e.IsMarked = i.IsMarked; e.IsMarked = i.IsMarked;
e.MinStock = i.MinStock; e.MinStock = i.MinStock;
e.MaxStock = i.MaxStock; e.MaxStock = i.MaxStock;

View file

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

View file

@ -24,13 +24,13 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
if (!string.IsNullOrWhiteSpace(req.Search)) if (!string.IsNullOrWhiteSpace(req.Search))
{ {
var s = req.Search.Trim().ToLower(); 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 total = await q.CountAsync(ct);
var items = await q var items = await q
.OrderByDescending(u => u.IsBase).ThenBy(u => u.Name) .OrderBy(u => u.Name)
.Skip(req.Skip).Take(req.Take) .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); .ToListAsync(ct);
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; 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) public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct)
{ {
var u = await _db.UnitsOfMeasure.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, 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")] [HttpPost, Authorize(Roles = "Admin,Manager")]
public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasureInput input, CancellationToken ct) 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 var e = new UnitOfMeasure
{ {
Code = input.Code, Code = input.Code,
Symbol = input.Symbol,
Name = input.Name, Name = input.Name,
DecimalPlaces = input.DecimalPlaces, Description = input.Description,
IsBase = input.IsBase,
IsActive = input.IsActive, IsActive = input.IsActive,
}; };
_db.UnitsOfMeasure.Add(e); _db.UnitsOfMeasure.Add(e);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id }, 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")] [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); var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound(); 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.Code = input.Code;
e.Symbol = input.Symbol;
e.Name = input.Name; e.Name = input.Name;
e.DecimalPlaces = input.DecimalPlaces; e.Description = input.Description;
e.IsBase = input.IsBase;
e.IsActive = input.IsActive; e.IsActive = input.IsActive;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); 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) .OrderBy(x => x.p.Name)
.Skip((page - 1) * pageSize).Take(pageSize) .Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new StockRow( .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.st.Id, x.st.Name,
x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity)) x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity))
.ToListAsync(ct); .ToListAsync(ct);

View file

@ -276,7 +276,7 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
where l.SupplyId == id where l.SupplyId == id
orderby l.SortOrder orderby l.SortOrder
select new SupplyLineDto( 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)) l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder))
.ToListAsync(ct); .ToListAsync(ct);

View file

@ -352,7 +352,7 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
where l.RetailSaleId == id where l.RetailSaleId == id
orderby l.SortOrder orderby l.SortOrder
select new RetailSaleLineDto( 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)) l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder))
.ToListAsync(ct); .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); var hasProducts = await db.Products.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct);
if (hasProducts) return; if (hasProducts) return;
var defaultVat = await db.VatRates.IgnoreQueryFilters() // KZ default VAT is 16% (applies as int on Product).
.FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.IsDefault, ct); const int vatDefault = 16;
var noVat = await db.VatRates.IgnoreQueryFilters() const int vat0 = 0;
.FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.Percent == 0m, ct);
var unitSht = await db.UnitsOfMeasure.IgnoreQueryFilters() 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() 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() 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; if (unitSht is null) return;
var vat = defaultVat.Id;
var vat0 = noVat?.Id ?? vat;
var retailPriceType = await db.PriceTypes.IgnoreQueryFilters() var retailPriceType = await db.PriceTypes.IgnoreQueryFilters()
.FirstOrDefaultAsync(p => p.OrganizationId == orgId && p.IsRetail, ct); .FirstOrDefaultAsync(p => p.OrganizationId == orgId && p.IsRetail, ct);
@ -88,7 +85,7 @@ Guid AddGroup(string name, Guid? parentId)
var supplier1 = new Counterparty var supplier1 = new Counterparty
{ {
OrganizationId = orgId, Name = "ТОО «Продтрейд»", LegalName = "Товарищество с ограниченной ответственностью «Продтрейд»", OrganizationId = orgId, Name = "ТОО «Продтрейд»", LegalName = "Товарищество с ограниченной ответственностью «Продтрейд»",
Kind = CounterpartyKind.Supplier, Type = CounterpartyType.LegalEntity, Type = CounterpartyType.LegalEntity,
Bin = "100140005678", CountryId = kz?.Id, Bin = "100140005678", CountryId = kz?.Id,
Address = "Алматы, ул. Абая 15", Phone = "+7 (727) 100-00-01", Address = "Алматы, ул. Абая 15", Phone = "+7 (727) 100-00-01",
Email = "order@prodtrade.kz", BankName = "Kaspi Bank", BankAccount = "KZ000000000000000001", Bik = "CASPKZKA", 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 var supplier2 = new Counterparty
{ {
OrganizationId = orgId, Name = "ИП Иванов А.С.", OrganizationId = orgId, Name = "ИП Иванов А.С.",
Kind = CounterpartyKind.Supplier, Type = CounterpartyType.Individual, Type = CounterpartyType.Individual,
Iin = "850101300000", CountryId = kz?.Id, Iin = "850101300000", CountryId = kz?.Id,
Phone = "+7 (777) 100-00-02", ContactPerson = "Иванов Алексей", Phone = "+7 (777) 100-00-02", ContactPerson = "Иванов Алексей",
IsActive = true, IsActive = true,
@ -106,49 +103,49 @@ Guid AddGroup(string name, Guid? parentId)
// Barcodes use generic internal range (2xxxxxxxxxxxx) to avoid colliding with real products. // Barcodes use generic internal range (2xxxxxxxxxxxx) to avoid colliding with real products.
// When user does real приёмка, real barcodes will overwrite. // 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), ("Вода питьевая «Тассай» 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, false), ("Вода питьевая «Тассай» 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, false), ("Сок «Да-Да» яблоко 1л", gDrinksNon, 650, "DR-JUC-001", "2000000000032", kz?.Id, false, unitSht.Id),
("Сок «Да-Да» апельсин 1л", gDrinksNon, 650, "DR-JUC-002", "2000000000049", kz?.Id, false, unitSht.Id, false), ("Сок «Да-Да» апельсин 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, false), ("Кока-кола 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, false), ("Пепси 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, false), ("Спрайт 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, false), ("Чай чёрный «Пиала» пакетированный, 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), ("Молоко «Простоквашино» 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, false), ("Молоко «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, false), ("Кефир «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, false), ("Йогурт «Актимель» клубника 100мл", gDairy, 180, "DAI-YOG-001", "2000000000124", null, false, unitSht.Id),
("Творог «Простоквашино» 5% 200г", gDairy, 590, "DAI-CRD-001", "2000000000131", ru?.Id, false, unitSht.Id, false), ("Творог «Простоквашино» 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, false), ("Сметана «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, false), ("Сыр «Российский» 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), ("Хлеб «Семейный» нарезной 500г", gBakery, 180, "BAK-BRD-001", "2000000000162", kz?.Id, false, unitSht.Id),
("Батон «Столичный» 400г", gBakery, 170, "BAK-BRD-002", "2000000000179", kz?.Id, false, unitSht.Id, false), ("Батон «Столичный» 400г", gBakery, 170, "BAK-BRD-002", "2000000000179", kz?.Id, false, unitSht.Id),
("Лепёшка домашняя", gBakery, 220, "BAK-LEP-001", "2000000000186", kz?.Id, false, unitSht.Id, false), ("Лепёшка домашняя", gBakery, 220, "BAK-LEP-001", "2000000000186", kz?.Id, false, unitSht.Id),
("Круассан шоколадный", gBakery, 320, "BAK-CRO-001", "2000000000193", kz?.Id, false, unitSht.Id, false), ("Круассан шоколадный", 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, 650, "SW-CHO-001", "2000000000209", kz?.Id, false, unitSht.Id),
("Шоколад «Алёнка» 100г", gSweets, 620, "SW-CHO-002", "2000000000216", ru?.Id, false, unitSht.Id, false), ("Шоколад «Алёнка» 100г", gSweets, 620, "SW-CHO-002", "2000000000216", ru?.Id, false, unitSht.Id),
("Конфеты «Казахстанские» 200г", gSweets, 890, "SW-CND-001", "2000000000223", kz?.Id, false, unitSht.Id, false), ("Конфеты «Казахстанские» 200г", gSweets, 890, "SW-CND-001", "2000000000223", kz?.Id, false, unitSht.Id),
("Печенье «Юбилейное» 250г", gSweets, 540, "SW-CKE-001", "2000000000230", ru?.Id, false, unitSht.Id, false), ("Печенье «Юбилейное» 250г", gSweets, 540, "SW-CKE-001", "2000000000230", ru?.Id, false, unitSht.Id),
("Вафли «Артек» 250г", gSweets, 480, "SW-WAF-001", "2000000000247", kz?.Id, false, unitSht.Id, false), ("Вафли «Артек» 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, 480, "GRO-SUG-001", "2000000000254", kz?.Id, false, unitSht.Id),
("Соль поваренная 1кг", gGrocery, 180, "GRO-SLT-001", "2000000000261", kz?.Id, false, unitSht.Id, false), ("Соль поваренная 1кг", gGrocery, 180, "GRO-SLT-001", "2000000000261", kz?.Id, false, unitSht.Id),
("Рис круглозёрный 1кг", gGrocery, 850, "GRO-RCE-001", "2000000000278", kz?.Id, false, unitSht.Id, false), ("Рис круглозёрный 1кг", gGrocery, 850, "GRO-RCE-001", "2000000000278", kz?.Id, false, unitSht.Id),
("Гречка «Мистраль» 900г", gGrocery, 990, "GRO-GRE-001", "2000000000285", ru?.Id, false, unitSht.Id, false), ("Гречка «Мистраль» 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, false), ("Макароны «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, false), ("Масло подсолнечное «Шедевр» 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, false), ("Кофе «Якобс Монарх» 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), ("Чипсы «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, false), ("Сухарики «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, false), ("Орешки фисташки 100г", gSnacks, 1190, "SN-NUT-001", "2000000000346", kz?.Id, false, unitSht.Id),
("Семечки «Мартин» 100г", gSnacks, 290, "SN-SED-001", "2000000000353", ru?.Id, false, unitSht.Id, false), ("Семечки «Мартин» 100г", gSnacks, 290, "SN-SED-001", "2000000000353", ru?.Id, false, unitSht.Id),
}; };
var products = demo.Select(d => var products = demo.Select(d =>
@ -159,12 +156,12 @@ Guid AddGroup(string name, Guid? parentId)
Name = d.Name, Name = d.Name,
Article = d.Article, Article = d.Article,
UnitOfMeasureId = d.Unit, UnitOfMeasureId = d.Unit,
VatRateId = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка") Vat = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")
? vat0 : vat, ? vat0 : vatDefault,
VatEnabled = !(d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")),
ProductGroupId = d.Group, ProductGroupId = d.Group,
CountryOfOriginId = d.Country, CountryOfOriginId = d.Country,
IsWeighed = d.IsWeighed, IsWeighed = d.IsWeighed,
IsAlcohol = d.IsAlcohol,
IsActive = true, IsActive = true,
PurchasePrice = Math.Round(d.RetailPrice * 0.72m, 2), PurchasePrice = Math.Round(d.RetailPrice * 0.72m, 2),
PurchaseCurrencyId = kzt.Id, 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) 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); var anyUnit = await db.UnitsOfMeasure.IgnoreQueryFilters().AnyAsync(u => u.OrganizationId == orgId, ct);
if (!anyUnit) if (!anyUnit)
{ {
db.UnitsOfMeasure.AddRange( db.UnitsOfMeasure.AddRange(
new UnitOfMeasure { OrganizationId = orgId, Code = "796", Symbol = "шт", Name = "штука", DecimalPlaces = 0, IsBase = true }, new UnitOfMeasure { OrganizationId = orgId, Code = "796", Name = "штука" },
new UnitOfMeasure { OrganizationId = orgId, Code = "166", Symbol = "кг", Name = "килограмм", DecimalPlaces = 3 }, new UnitOfMeasure { OrganizationId = orgId, Code = "166", Name = "килограмм" },
new UnitOfMeasure { OrganizationId = orgId, Code = "112", Symbol = "л", Name = "литр", DecimalPlaces = 3 }, new UnitOfMeasure { OrganizationId = orgId, Code = "112", Name = "литр" },
new UnitOfMeasure { OrganizationId = orgId, Code = "006", Symbol = "м", Name = "метр", DecimalPlaces = 3 }, new UnitOfMeasure { OrganizationId = orgId, Code = "006", Name = "метр" },
new UnitOfMeasure { OrganizationId = orgId, Code = "625", Symbol = "уп", Name = "упаковка", DecimalPlaces = 0 } new UnitOfMeasure { OrganizationId = orgId, Code = "625", Name = "упаковка" }
); );
} }
@ -116,7 +107,6 @@ private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
OrganizationId = orgId, OrganizationId = orgId,
Name = "Основной склад", Name = "Основной склад",
Code = "MAIN", Code = "MAIN",
Kind = StoreKind.Warehouse,
IsMain = true, IsMain = true,
Address = "Алматы, ул. Пример 1", 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 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( 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( public record PriceTypeDto(
Guid Id, string Name, bool IsDefault, bool IsRetail, int SortOrder, bool IsActive); Guid Id, string Name, bool IsDefault, bool IsRetail, int SortOrder, bool IsActive);
public record StoreDto( 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); string? ManagerName, bool IsMain, bool IsActive);
public record RetailPointDto( public record RetailPointDto(
@ -27,7 +24,7 @@ public record ProductGroupDto(
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, bool IsActive); Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, bool IsActive);
public record CounterpartyDto( 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? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? CountryName,
string? Address, string? Phone, string? Email, string? Address, string? Phone, string? Email,
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive); 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( public record ProductDto(
Guid Id, string Name, string? Article, string? Description, Guid Id, string Name, string? Article, string? Description,
Guid UnitOfMeasureId, string UnitSymbol, Guid UnitOfMeasureId, string UnitName,
Guid VatRateId, decimal VatPercent, int Vat, bool VatEnabled,
Guid? ProductGroupId, string? ProductGroupName, Guid? ProductGroupId, string? ProductGroupName,
Guid? DefaultSupplierId, string? DefaultSupplierName, Guid? DefaultSupplierId, string? DefaultSupplierName,
Guid? CountryOfOriginId, string? CountryOfOriginName, Guid? CountryOfOriginId, string? CountryOfOriginName,
bool IsService, bool IsWeighed, bool IsAlcohol, bool IsMarked, bool IsService, bool IsWeighed, bool IsMarked,
decimal? MinStock, decimal? MaxStock, decimal? MinStock, decimal? MaxStock,
decimal? PurchasePrice, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode, decimal? PurchasePrice, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
string? ImageUrl, bool IsActive, string? ImageUrl, bool IsActive,
@ -53,11 +50,10 @@ public record ProductDto(
// Upsert payloads (input) // Upsert payloads (input)
public record CountryInput(string Code, string Name, int SortOrder = 0); 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 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 Name, string? Description = null, bool IsActive = true);
public record UnitOfMeasureInput(string Code, string Symbol, string Name, int DecimalPlaces, bool IsBase = false, bool IsActive = true);
public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true); public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true);
public record StoreInput( public record StoreInput(
string Name, string? Code, StoreKind Kind = StoreKind.Warehouse, string Name, string? Code,
string? Address = null, string? Phone = null, string? ManagerName = null, string? Address = null, string? Phone = null, string? ManagerName = null,
bool IsMain = false, bool IsActive = true); bool IsMain = false, bool IsActive = true);
public record RetailPointInput( public record RetailPointInput(
@ -66,7 +62,7 @@ public record RetailPointInput(
string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true); string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true);
public record ProductGroupInput(string Name, Guid? ParentId, int SortOrder = 0, bool IsActive = true); public record ProductGroupInput(string Name, Guid? ParentId, int SortOrder = 0, bool IsActive = true);
public record CounterpartyInput( 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? Bin, string? Iin, string? TaxNumber, Guid? CountryId,
string? Address, string? Phone, string? Email, string? Address, string? Phone, string? Email,
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive = true); 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 ProductPriceInput(Guid PriceTypeId, decimal Amount, Guid CurrencyId);
public record ProductInput( public record ProductInput(
string Name, string? Article, string? Description, string Name, string? Article, string? Description,
Guid UnitOfMeasureId, Guid VatRateId, Guid UnitOfMeasureId, int Vat, bool VatEnabled,
Guid? ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId, 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? MinStock = null, decimal? MaxStock = null,
decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null, decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null,
string? ImageUrl = null, bool IsActive = true, string? ImageUrl = null, bool IsActive = true,

View file

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

View file

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

View file

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

View file

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

View file

@ -2,13 +2,11 @@
namespace foodmarket.Domain.Catalog; namespace foodmarket.Domain.Catalog;
// Tenant-scoped справочник единиц измерения. // Единица измерения как в MoySklad entity/uom: code + name + description.
public class UnitOfMeasure : TenantEntity public class UnitOfMeasure : TenantEntity
{ {
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л) public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
public string Symbol { get; set; } = null!; // "шт", "кг", "л", "м"
public string Name { get; set; } = null!; // "штука", "килограмм", "литр" public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
public int DecimalPlaces { get; set; } // 0 для шт, 3 для кг/л public string? Description { get; set; }
public bool IsBase { get; set; } // базовая единица этой организации
public bool IsActive { get; set; } = true; 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."); var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще — это // MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще —
// не наша выдумка, проверено через API: counterparty entity содержит только // counterparty entity содержит только group (группа доступа), tags
// group (группа доступа), tags (произвольные), state (пользовательская цепочка // (произвольные), state (пользовательская цепочка статусов), companyType
// статусов), companyType (legal/individual/entrepreneur). Никакой role/kind. // (legal/individual/entrepreneur). Никакого role/kind. Поэтому у нас тоже
// Поэтому при импорте ВСЕГДА ставим Unspecified — пользователь сам решит. // этого поля нет — пусть пользователь сам решит.
// Параметр tags оставлен ради совместимости сигнатуры, не используется.
static foodmarket.Domain.Catalog.CounterpartyKind ResolveKind(IReadOnlyList<string>? tags)
=> foodmarket.Domain.Catalog.CounterpartyKind.Unspecified;
static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType) static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType)
=> companyType switch => companyType switch
@ -83,7 +80,6 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
OrganizationId = orgId, OrganizationId = orgId,
Name = Trim(c.Name, 255) ?? c.Name, Name = Trim(c.Name, 255) ?? c.Name,
LegalName = Trim(c.LegalTitle, 500), LegalName = Trim(c.LegalTitle, 500),
Kind = ResolveKind(c.Tags),
Type = ResolveType(c.CompanyType), Type = ResolveType(c.CompanyType),
Bin = Trim(c.Inn, 20), Bin = Trim(c.Inn, 20),
TaxNumber = Trim(c.Kpp, 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."); var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
// Pre-load tenant defaults. // Pre-load tenant defaults. KZ default VAT is 16% — applied when product didn't
var defaultVat = await _db.VatRates.FirstOrDefaultAsync(v => v.IsDefault, ct) // carry its own vat from MoySklad.
?? await _db.VatRates.FirstAsync(ct); const int kzDefaultVat = 16;
var vatByPercent = await _db.VatRates.ToDictionaryAsync(v => (int)v.Percent, v => v.Id, ct); var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.Code == "796", ct)
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.IsBase, ct)
?? await _db.UnitsOfMeasure.FirstAsync(ct); ?? await _db.UnitsOfMeasure.FirstAsync(ct);
var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct) var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct)
?? await _db.PriceTypes.FirstAsync(ct); ?? await _db.PriceTypes.FirstAsync(ct);
@ -194,7 +189,8 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
try 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 Guid? groupId = p.ProductFolder?.Meta?.Href is { } href && TryExtractId(href) is { } msGroupId
&& localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : null; && localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : null;
Guid? countryId = p.Country?.Name is { } cn && countriesByName.TryGetValue(cn, out var cId) ? cId : 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), Article = Trim(article, 500),
Description = p.Description, Description = p.Description,
UnitOfMeasureId = baseUnit.Id, UnitOfMeasureId = baseUnit.Id,
VatRateId = vatId, Vat = vat,
VatEnabled = vatEnabled,
ProductGroupId = groupId, ProductGroupId = groupId,
CountryOfOriginId = countryId, CountryOfOriginId = countryId,
IsWeighed = p.Weighed, IsWeighed = p.Weighed,
IsAlcohol = p.Alcoholic is not null,
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED", IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
IsActive = !p.Archived, IsActive = !p.Archived,
PurchasePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m, 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<Country> Countries => Set<Country>();
public DbSet<Currency> Currencies => Set<Currency>(); public DbSet<Currency> Currencies => Set<Currency>();
public DbSet<VatRate> VatRates => Set<VatRate>();
public DbSet<UnitOfMeasure> UnitsOfMeasure => Set<UnitOfMeasure>(); public DbSet<UnitOfMeasure> UnitsOfMeasure => Set<UnitOfMeasure>();
public DbSet<Counterparty> Counterparties => Set<Counterparty>(); public DbSet<Counterparty> Counterparties => Set<Counterparty>();
public DbSet<Store> Stores => Set<Store>(); 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<Country>(ConfigureCountry);
b.Entity<Currency>(ConfigureCurrency); b.Entity<Currency>(ConfigureCurrency);
b.Entity<VatRate>(ConfigureVatRate);
b.Entity<UnitOfMeasure>(ConfigureUnit); b.Entity<UnitOfMeasure>(ConfigureUnit);
b.Entity<Counterparty>(ConfigureCounterparty); b.Entity<Counterparty>(ConfigureCounterparty);
b.Entity<Store>(ConfigureStore); b.Entity<Store>(ConfigureStore);
@ -40,20 +39,12 @@ private static void ConfigureCurrency(EntityTypeBuilder<Currency> b)
b.HasIndex(x => x.Code).IsUnique(); 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) private static void ConfigureUnit(EntityTypeBuilder<UnitOfMeasure> b)
{ {
b.ToTable("units_of_measure"); b.ToTable("units_of_measure");
b.Property(x => x.Code).HasMaxLength(10).IsRequired(); 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.Name).HasMaxLength(100).IsRequired();
b.Property(x => x.Description).HasMaxLength(500);
b.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique(); 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.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.Name });
b.HasIndex(x => new { x.OrganizationId, x.Bin }); b.HasIndex(x => new { x.OrganizationId, x.Bin });
b.HasIndex(x => new { x.OrganizationId, x.Kind });
} }
private static void ConfigureStore(EntityTypeBuilder<Store> b) 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.Property(x => x.ImageUrl).HasMaxLength(1000);
b.HasOne(x => x.UnitOfMeasure).WithMany().HasForeignKey(x => x.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict); 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.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.DefaultSupplier).WithMany().HasForeignKey(x => x.DefaultSupplierId).OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.CountryOfOrigin).WithMany().HasForeignKey(x => x.CountryOfOriginId).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 { DashboardPage } from '@/pages/DashboardPage'
import { CountriesPage } from '@/pages/CountriesPage' import { CountriesPage } from '@/pages/CountriesPage'
import { CurrenciesPage } from '@/pages/CurrenciesPage' import { CurrenciesPage } from '@/pages/CurrenciesPage'
import { VatRatesPage } from '@/pages/VatRatesPage'
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage' import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
import { PriceTypesPage } from '@/pages/PriceTypesPage' import { PriceTypesPage } from '@/pages/PriceTypesPage'
import { StoresPage } from '@/pages/StoresPage' import { StoresPage } from '@/pages/StoresPage'
@ -46,7 +45,6 @@ export default function App() {
<Route path="/catalog/products/:id" element={<ProductEditPage />} /> <Route path="/catalog/products/:id" element={<ProductEditPage />} />
<Route path="/catalog/product-groups" element={<ProductGroupsPage />} /> <Route path="/catalog/product-groups" element={<ProductGroupsPage />} />
<Route path="/catalog/units" element={<UnitsOfMeasurePage />} /> <Route path="/catalog/units" element={<UnitsOfMeasurePage />} />
<Route path="/catalog/vat-rates" element={<VatRatesPage />} />
<Route path="/catalog/price-types" element={<PriceTypesPage />} /> <Route path="/catalog/price-types" element={<PriceTypesPage />} />
<Route path="/catalog/counterparties" element={<CounterpartiesPage />} /> <Route path="/catalog/counterparties" element={<CounterpartiesPage />} />
<Route path="/catalog/stores" element={<StoresPage />} /> <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"> <div className="text-xs text-slate-400 flex gap-2 font-mono">
{p.article && <span>{p.article}</span>} {p.article && <span>{p.article}</span>}
{p.barcodes[0] && <span>· {p.barcodes[0].code}</span>} {p.barcodes[0] && <span>· {p.barcodes[0].code}</span>}
<span>· {p.unitSymbol}</span> <span>· {p.unitName}</span>
</div> </div>
</div> </div>
{p.purchasePrice !== null && ( {p.purchasePrice !== null && (

View file

@ -6,25 +6,18 @@ export interface PagedResult<T> {
totalPages: number 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 const CounterpartyType = { LegalEntity: 1, Individual: 2 } as const
export type CounterpartyType = (typeof CounterpartyType)[keyof typeof CounterpartyType] 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 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 type BarcodeType = (typeof BarcodeType)[keyof typeof BarcodeType]
export interface Country { id: string; code: string; name: string; sortOrder: number } 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 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; name: string; description: string | null; isActive: boolean }
export interface UnitOfMeasure { id: string; code: string; symbol: string; name: string; decimalPlaces: number; isBase: boolean; isActive: boolean }
export interface PriceType { id: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean } export interface PriceType { id: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean }
export interface Store { 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 managerName: string | null; isMain: boolean; isActive: boolean
} }
export interface RetailPoint { 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 ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; isActive: boolean }
export interface Counterparty { 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; bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null;
address: string | null; phone: string | null; email: string | null; address: string | null; phone: string | null; email: string | null;
bankName: string | null; bankAccount: string | null; bik: 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 ProductPrice { id: string; priceTypeId: string; priceTypeName: string; amount: number; currencyId: string; currencyCode: string }
export interface Product { export interface Product {
id: string; name: string; article: string | null; description: string | null; id: string; name: string; article: string | null; description: string | null;
unitOfMeasureId: string; unitSymbol: string; unitOfMeasureId: string; unitName: string;
vatRateId: string; vatPercent: number; vat: number; vatEnabled: boolean;
productGroupId: string | null; productGroupName: string | null; productGroupId: string | null; productGroupName: string | null;
defaultSupplierId: string | null; defaultSupplierName: string | null; defaultSupplierId: string | null; defaultSupplierName: string | null;
countryOfOriginId: string | null; countryOfOriginName: 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; minStock: number | null; maxStock: number | null;
purchasePrice: number | null; purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null; purchasePrice: number | null; purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null;
imageUrl: string | null; isActive: boolean; imageUrl: string | null; isActive: boolean;
@ -56,7 +49,7 @@ export interface Product {
} }
export interface StockRow { 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; storeId: string; storeName: string;
quantity: number; reservedQuantity: number; available: number; quantity: number; reservedQuantity: number; available: number;
} }
@ -83,7 +76,7 @@ export interface SupplyListRow {
export interface SupplyLineDto { export interface SupplyLineDto {
id: string | null; productId: string; 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; quantity: number; unitPrice: number; lineTotal: number; sortOrder: number;
} }
@ -116,7 +109,7 @@ export interface RetailSaleListRow {
export interface RetailSaleLineDto { export interface RetailSaleLineDto {
id: string | null; productId: string; 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; quantity: number; unitPrice: number; discount: number; lineTotal: number;
vatPercent: number; sortOrder: number; vatPercent: number; sortOrder: number;
} }

View file

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

View file

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

View file

@ -45,13 +45,12 @@ export function ProductsPage() {
</div> </div>
)}, )},
{ header: 'Группа', width: '200px', cell: (r) => r.productGroupName ?? '—' }, { header: 'Группа', width: '200px', cell: (r) => r.productGroupName ?? '—' },
{ header: 'Ед.', width: '70px', cell: (r) => r.unitSymbol }, { header: 'Ед.', width: '70px', cell: (r) => r.unitName },
{ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vatPercent}%` }, { header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vat}%` },
{ header: 'Тип', width: '140px', cell: (r) => ( { header: 'Тип', width: '140px', cell: (r) => (
<div className="flex gap-1 flex-wrap"> <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.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.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>} {r.isMarked && <span className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">Маркир.</span>}
</div> </div>
)}, )},

View file

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

View file

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

View file

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

View file

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