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
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:
parent
3fd2f8a223
commit
8fc9ef1a2e
1
.claude/scheduled_tasks.lock
Normal file
1
.claude/scheduled_tasks.lock
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"sessionId":"791848bb-ad06-4ccc-9c5a-13da4ee36524","pid":353363,"acquiredAt":1776883641374}
|
||||
|
|
@ -56,13 +56,6 @@ jobs:
|
|||
name: Deploy stage
|
||||
runs-on: [self-hosted, linux]
|
||||
needs: [api, web]
|
||||
# Temporary guard: main currently contains references to VatRate etc. while
|
||||
# the stage DB is already on Phase2c3_MsStrict (vat_rates dropped). Until
|
||||
# the user lands the matching code changes on main, an auto-deploy of a
|
||||
# freshly built image from main would break the API. Run deploy only on
|
||||
# tag/dispatch; normal pushes still build + push images to the local
|
||||
# registry, but don't roll the stage forward.
|
||||
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
|
|
|||
|
|
@ -20,14 +20,9 @@ public class CounterpartiesController : ControllerBase
|
|||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<CounterpartyDto>>> List(
|
||||
[FromQuery] PagedRequest req,
|
||||
[FromQuery] CounterpartyKind? kind,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var q = _db.Counterparties.Include(c => c.Country).AsNoTracking().AsQueryable();
|
||||
if (kind is not null)
|
||||
{
|
||||
q = q.Where(c => c.Kind == kind || c.Kind == CounterpartyKind.Both);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||
{
|
||||
var s = req.Search.Trim().ToLower();
|
||||
|
|
@ -43,7 +38,7 @@ public class CounterpartiesController : ControllerBase
|
|||
.OrderBy(c => c.Name)
|
||||
.Skip(req.Skip).Take(req.Take)
|
||||
.Select(c => new CounterpartyDto(
|
||||
c.Id, c.Name, c.LegalName, c.Kind, c.Type,
|
||||
c.Id, c.Name, c.LegalName, c.Type,
|
||||
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country != null ? c.Country.Name : null,
|
||||
c.Address, c.Phone, c.Email,
|
||||
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive))
|
||||
|
|
@ -56,7 +51,7 @@ public async Task<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken
|
|||
{
|
||||
var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
return c is null ? NotFound() : new CounterpartyDto(
|
||||
c.Id, c.Name, c.LegalName, c.Kind, c.Type,
|
||||
c.Id, c.Name, c.LegalName, c.Type,
|
||||
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
|
||||
c.Address, c.Phone, c.Email,
|
||||
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive);
|
||||
|
|
@ -95,7 +90,6 @@ private static Counterparty Apply(Counterparty e, CounterpartyInput i)
|
|||
{
|
||||
e.Name = i.Name;
|
||||
e.LegalName = i.LegalName;
|
||||
e.Kind = i.Kind;
|
||||
e.Type = i.Type;
|
||||
e.Bin = i.Bin;
|
||||
e.Iin = i.Iin;
|
||||
|
|
@ -117,7 +111,7 @@ private async Task<CounterpartyDto> ProjectAsync(Guid id, CancellationToken ct)
|
|||
{
|
||||
var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstAsync(x => x.Id == id, ct);
|
||||
return new CounterpartyDto(
|
||||
c.Id, c.Name, c.LegalName, c.Kind, c.Type,
|
||||
c.Id, c.Name, c.LegalName, c.Type,
|
||||
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
|
||||
c.Address, c.Phone, c.Email,
|
||||
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive);
|
||||
|
|
|
|||
|
|
@ -111,7 +111,6 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
|||
|
||||
private IQueryable<Product> QueryIncludes() => _db.Products
|
||||
.Include(p => p.UnitOfMeasure)
|
||||
.Include(p => p.VatRate)
|
||||
.Include(p => p.ProductGroup)
|
||||
.Include(p => p.DefaultSupplier)
|
||||
.Include(p => p.CountryOfOrigin)
|
||||
|
|
@ -126,12 +125,12 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
|||
private static readonly System.Linq.Expressions.Expression<Func<Product, ProductDto>> Projection = p =>
|
||||
new ProductDto(
|
||||
p.Id, p.Name, p.Article, p.Description,
|
||||
p.UnitOfMeasureId, p.UnitOfMeasure!.Symbol,
|
||||
p.VatRateId, p.VatRate!.Percent,
|
||||
p.UnitOfMeasureId, p.UnitOfMeasure!.Name,
|
||||
p.Vat, p.VatEnabled,
|
||||
p.ProductGroupId, p.ProductGroup != null ? p.ProductGroup.Name : null,
|
||||
p.DefaultSupplierId, p.DefaultSupplier != null ? p.DefaultSupplier.Name : null,
|
||||
p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null,
|
||||
p.IsService, p.IsWeighed, p.IsAlcohol, p.IsMarked,
|
||||
p.IsService, p.IsWeighed, p.IsMarked,
|
||||
p.MinStock, p.MaxStock,
|
||||
p.PurchasePrice, p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
|
||||
p.ImageUrl, p.IsActive,
|
||||
|
|
@ -144,13 +143,13 @@ private static void Apply(Product e, ProductInput i)
|
|||
e.Article = i.Article;
|
||||
e.Description = i.Description;
|
||||
e.UnitOfMeasureId = i.UnitOfMeasureId;
|
||||
e.VatRateId = i.VatRateId;
|
||||
e.Vat = i.Vat;
|
||||
e.VatEnabled = i.VatEnabled;
|
||||
e.ProductGroupId = i.ProductGroupId;
|
||||
e.DefaultSupplierId = i.DefaultSupplierId;
|
||||
e.CountryOfOriginId = i.CountryOfOriginId;
|
||||
e.IsService = i.IsService;
|
||||
e.IsWeighed = i.IsWeighed;
|
||||
e.IsAlcohol = i.IsAlcohol;
|
||||
e.IsMarked = i.IsMarked;
|
||||
e.MinStock = i.MinStock;
|
||||
e.MaxStock = i.MaxStock;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ public async Task<ActionResult<PagedResult<StoreDto>>> List([FromQuery] PagedReq
|
|||
var items = await q
|
||||
.OrderByDescending(x => x.IsMain).ThenBy(x => x.Name)
|
||||
.Skip(req.Skip).Take(req.Take)
|
||||
.Select(x => new StoreDto(x.Id, x.Name, x.Code, x.Kind, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive))
|
||||
.Select(x => new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<StoreDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ public async Task<ActionResult<PagedResult<StoreDto>>> List([FromQuery] PagedReq
|
|||
public async Task<ActionResult<StoreDto>> Get(Guid id, CancellationToken ct)
|
||||
{
|
||||
var x = await _db.Stores.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
return x is null ? NotFound() : new StoreDto(x.Id, x.Name, x.Code, x.Kind, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive);
|
||||
return x is null ? NotFound() : new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive);
|
||||
}
|
||||
|
||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||
|
|
@ -51,13 +51,13 @@ public async Task<ActionResult<StoreDto>> Create([FromBody] StoreInput input, Ca
|
|||
}
|
||||
var e = new Store
|
||||
{
|
||||
Name = input.Name, Code = input.Code, Kind = input.Kind, Address = input.Address,
|
||||
Name = input.Name, Code = input.Code,Address = input.Address,
|
||||
Phone = input.Phone, ManagerName = input.ManagerName, IsMain = input.IsMain, IsActive = input.IsActive,
|
||||
};
|
||||
_db.Stores.Add(e);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
||||
new StoreDto(e.Id, e.Name, e.Code, e.Kind, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive));
|
||||
new StoreDto(e.Id, e.Name, e.Code, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
||||
|
|
@ -71,7 +71,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] StoreInput input, Ca
|
|||
}
|
||||
e.Name = input.Name;
|
||||
e.Code = input.Code;
|
||||
e.Kind = input.Kind;
|
||||
|
||||
e.Address = input.Address;
|
||||
e.Phone = input.Phone;
|
||||
e.ManagerName = input.ManagerName;
|
||||
|
|
|
|||
|
|
@ -24,13 +24,13 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
|
|||
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||
{
|
||||
var s = req.Search.Trim().ToLower();
|
||||
q = q.Where(u => u.Name.ToLower().Contains(s) || u.Symbol.ToLower().Contains(s) || u.Code.ToLower().Contains(s));
|
||||
q = q.Where(u => u.Name.ToLower().Contains(s) || u.Code.ToLower().Contains(s));
|
||||
}
|
||||
var total = await q.CountAsync(ct);
|
||||
var items = await q
|
||||
.OrderByDescending(u => u.IsBase).ThenBy(u => u.Name)
|
||||
.OrderBy(u => u.Name)
|
||||
.Skip(req.Skip).Take(req.Take)
|
||||
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Symbol, u.Name, u.DecimalPlaces, u.IsBase, u.IsActive))
|
||||
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.IsActive))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
|
@ -39,30 +39,23 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
|
|||
public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct)
|
||||
{
|
||||
var u = await _db.UnitsOfMeasure.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Symbol, u.Name, u.DecimalPlaces, u.IsBase, u.IsActive);
|
||||
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.IsActive);
|
||||
}
|
||||
|
||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||
public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasureInput input, CancellationToken ct)
|
||||
{
|
||||
if (input.IsBase)
|
||||
{
|
||||
await _db.UnitsOfMeasure.Where(u => u.IsBase).ExecuteUpdateAsync(s => s.SetProperty(u => u.IsBase, false), ct);
|
||||
}
|
||||
|
||||
var e = new UnitOfMeasure
|
||||
{
|
||||
Code = input.Code,
|
||||
Symbol = input.Symbol,
|
||||
Name = input.Name,
|
||||
DecimalPlaces = input.DecimalPlaces,
|
||||
IsBase = input.IsBase,
|
||||
Description = input.Description,
|
||||
IsActive = input.IsActive,
|
||||
};
|
||||
_db.UnitsOfMeasure.Add(e);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
||||
new UnitOfMeasureDto(e.Id, e.Code, e.Symbol, e.Name, e.DecimalPlaces, e.IsBase, e.IsActive));
|
||||
new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description, e.IsActive));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
||||
|
|
@ -71,16 +64,9 @@ public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput i
|
|||
var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (e is null) return NotFound();
|
||||
|
||||
if (input.IsBase && !e.IsBase)
|
||||
{
|
||||
await _db.UnitsOfMeasure.Where(u => u.IsBase && u.Id != id).ExecuteUpdateAsync(s => s.SetProperty(u => u.IsBase, false), ct);
|
||||
}
|
||||
|
||||
e.Code = input.Code;
|
||||
e.Symbol = input.Symbol;
|
||||
e.Name = input.Name;
|
||||
e.DecimalPlaces = input.DecimalPlaces;
|
||||
e.IsBase = input.IsBase;
|
||||
e.Description = input.Description;
|
||||
e.IsActive = input.IsActive;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return NoContent();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@ public record StockRow(
|
|||
.OrderBy(x => x.p.Name)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(x => new StockRow(
|
||||
x.p.Id, x.p.Name, x.p.Article, x.u.Symbol,
|
||||
x.p.Id, x.p.Name, x.p.Article, x.u.Name,
|
||||
x.st.Id, x.st.Name,
|
||||
x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity))
|
||||
.ToListAsync(ct);
|
||||
|
|
|
|||
|
|
@ -276,7 +276,7 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
|
|||
where l.SupplyId == id
|
||||
orderby l.SortOrder
|
||||
select new SupplyLineDto(
|
||||
l.Id, l.ProductId, p.Name, p.Article, u.Symbol,
|
||||
l.Id, l.ProductId, p.Name, p.Article, u.Name,
|
||||
l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder))
|
||||
.ToListAsync(ct);
|
||||
|
||||
|
|
|
|||
|
|
@ -352,7 +352,7 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
|
|||
where l.RetailSaleId == id
|
||||
orderby l.SortOrder
|
||||
select new RetailSaleLineDto(
|
||||
l.Id, l.ProductId, p.Name, p.Article, u.Symbol,
|
||||
l.Id, l.ProductId, p.Name, p.Article, u.Name,
|
||||
l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder))
|
||||
.ToListAsync(ct);
|
||||
|
||||
|
|
|
|||
|
|
@ -32,21 +32,18 @@ public async Task StartAsync(CancellationToken ct)
|
|||
var hasProducts = await db.Products.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct);
|
||||
if (hasProducts) return;
|
||||
|
||||
var defaultVat = await db.VatRates.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.IsDefault, ct);
|
||||
var noVat = await db.VatRates.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.Percent == 0m, ct);
|
||||
// KZ default VAT is 16% (applies as int on Product).
|
||||
const int vatDefault = 16;
|
||||
const int vat0 = 0;
|
||||
|
||||
var unitSht = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "шт", ct);
|
||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "796", ct);
|
||||
var unitKg = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "кг", ct);
|
||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "166", ct);
|
||||
var unitL = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "л", ct);
|
||||
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Code == "112", ct);
|
||||
|
||||
if (defaultVat is null || unitSht is null) return;
|
||||
var vat = defaultVat.Id;
|
||||
var vat0 = noVat?.Id ?? vat;
|
||||
if (unitSht is null) return;
|
||||
|
||||
var retailPriceType = await db.PriceTypes.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(p => p.OrganizationId == orgId && p.IsRetail, ct);
|
||||
|
|
@ -88,7 +85,7 @@ Guid AddGroup(string name, Guid? parentId)
|
|||
var supplier1 = new Counterparty
|
||||
{
|
||||
OrganizationId = orgId, Name = "ТОО «Продтрейд»", LegalName = "Товарищество с ограниченной ответственностью «Продтрейд»",
|
||||
Kind = CounterpartyKind.Supplier, Type = CounterpartyType.LegalEntity,
|
||||
Type = CounterpartyType.LegalEntity,
|
||||
Bin = "100140005678", CountryId = kz?.Id,
|
||||
Address = "Алматы, ул. Абая 15", Phone = "+7 (727) 100-00-01",
|
||||
Email = "order@prodtrade.kz", BankName = "Kaspi Bank", BankAccount = "KZ000000000000000001", Bik = "CASPKZKA",
|
||||
|
|
@ -97,7 +94,7 @@ Guid AddGroup(string name, Guid? parentId)
|
|||
var supplier2 = new Counterparty
|
||||
{
|
||||
OrganizationId = orgId, Name = "ИП Иванов А.С.",
|
||||
Kind = CounterpartyKind.Supplier, Type = CounterpartyType.Individual,
|
||||
Type = CounterpartyType.Individual,
|
||||
Iin = "850101300000", CountryId = kz?.Id,
|
||||
Phone = "+7 (777) 100-00-02", ContactPerson = "Иванов Алексей",
|
||||
IsActive = true,
|
||||
|
|
@ -106,49 +103,49 @@ Guid AddGroup(string name, Guid? parentId)
|
|||
|
||||
// Barcodes use generic internal range (2xxxxxxxxxxxx) to avoid colliding with real products.
|
||||
// When user does real приёмка, real barcodes will overwrite.
|
||||
var demo = new (string Name, Guid Group, decimal RetailPrice, string Article, string Barcode, Guid? Country, bool IsWeighed, Guid Unit, bool IsAlcohol)[]
|
||||
var demo = new (string Name, Guid Group, decimal RetailPrice, string Article, string Barcode, Guid? Country, bool IsWeighed, Guid Unit)[]
|
||||
{
|
||||
// Напитки — безалкогольные
|
||||
("Вода питьевая «Тассай» 0.5л", gDrinksNon, 220, "DR-WAT-001", "2000000000018", kz?.Id, false, unitSht.Id, false),
|
||||
("Вода питьевая «Тассай» 1.5л", gDrinksNon, 380, "DR-WAT-002", "2000000000025", kz?.Id, false, unitSht.Id, false),
|
||||
("Сок «Да-Да» яблоко 1л", gDrinksNon, 650, "DR-JUC-001", "2000000000032", kz?.Id, false, unitSht.Id, false),
|
||||
("Сок «Да-Да» апельсин 1л", gDrinksNon, 650, "DR-JUC-002", "2000000000049", kz?.Id, false, unitSht.Id, false),
|
||||
("Кока-кола 0.5л", gDrinksNon, 420, "DR-SOD-001", "2000000000056", ru?.Id, false, unitSht.Id, false),
|
||||
("Пепси 0.5л", gDrinksNon, 420, "DR-SOD-002", "2000000000063", ru?.Id, false, unitSht.Id, false),
|
||||
("Спрайт 0.5л", gDrinksNon, 420, "DR-SOD-003", "2000000000070", ru?.Id, false, unitSht.Id, false),
|
||||
("Чай чёрный «Пиала» пакетированный, 100шт", gDrinksNon, 1250, "DR-TEA-001", "2000000000087", kz?.Id, false, unitSht.Id, false),
|
||||
("Вода питьевая «Тассай» 0.5л", gDrinksNon, 220, "DR-WAT-001", "2000000000018", kz?.Id, false, unitSht.Id),
|
||||
("Вода питьевая «Тассай» 1.5л", gDrinksNon, 380, "DR-WAT-002", "2000000000025", kz?.Id, false, unitSht.Id),
|
||||
("Сок «Да-Да» яблоко 1л", gDrinksNon, 650, "DR-JUC-001", "2000000000032", kz?.Id, false, unitSht.Id),
|
||||
("Сок «Да-Да» апельсин 1л", gDrinksNon, 650, "DR-JUC-002", "2000000000049", kz?.Id, false, unitSht.Id),
|
||||
("Кока-кола 0.5л", gDrinksNon, 420, "DR-SOD-001", "2000000000056", ru?.Id, false, unitSht.Id),
|
||||
("Пепси 0.5л", gDrinksNon, 420, "DR-SOD-002", "2000000000063", ru?.Id, false, unitSht.Id),
|
||||
("Спрайт 0.5л", gDrinksNon, 420, "DR-SOD-003", "2000000000070", ru?.Id, false, unitSht.Id),
|
||||
("Чай чёрный «Пиала» пакетированный, 100шт", gDrinksNon, 1250, "DR-TEA-001", "2000000000087", kz?.Id, false, unitSht.Id),
|
||||
// Молочные
|
||||
("Молоко «Простоквашино» 2.5% 930мл", gDairy, 720, "DAI-MLK-001", "2000000000094", ru?.Id, false, unitSht.Id, false),
|
||||
("Молоко «Food Master» 3.2% 1л", gDairy, 620, "DAI-MLK-002", "2000000000100", kz?.Id, false, unitSht.Id, false),
|
||||
("Кефир «Food Master» 2.5% 1л", gDairy, 590, "DAI-KEF-001", "2000000000117", kz?.Id, false, unitSht.Id, false),
|
||||
("Йогурт «Актимель» клубника 100мл", gDairy, 180, "DAI-YOG-001", "2000000000124", null, false, unitSht.Id, false),
|
||||
("Творог «Простоквашино» 5% 200г", gDairy, 590, "DAI-CRD-001", "2000000000131", ru?.Id, false, unitSht.Id, false),
|
||||
("Сметана «Food Master» 20% 400г", gDairy, 850, "DAI-SCR-001", "2000000000148", kz?.Id, false, unitSht.Id, false),
|
||||
("Сыр «Российский» 45%", gDairy, 3200, "DAI-CHE-001", "2000000000155", kz?.Id, true, unitKg?.Id ?? unitSht.Id, false),
|
||||
("Молоко «Простоквашино» 2.5% 930мл", gDairy, 720, "DAI-MLK-001", "2000000000094", ru?.Id, false, unitSht.Id),
|
||||
("Молоко «Food Master» 3.2% 1л", gDairy, 620, "DAI-MLK-002", "2000000000100", kz?.Id, false, unitSht.Id),
|
||||
("Кефир «Food Master» 2.5% 1л", gDairy, 590, "DAI-KEF-001", "2000000000117", kz?.Id, false, unitSht.Id),
|
||||
("Йогурт «Актимель» клубника 100мл", gDairy, 180, "DAI-YOG-001", "2000000000124", null, false, unitSht.Id),
|
||||
("Творог «Простоквашино» 5% 200г", gDairy, 590, "DAI-CRD-001", "2000000000131", ru?.Id, false, unitSht.Id),
|
||||
("Сметана «Food Master» 20% 400г", gDairy, 850, "DAI-SCR-001", "2000000000148", kz?.Id, false, unitSht.Id),
|
||||
("Сыр «Российский» 45%", gDairy, 3200, "DAI-CHE-001", "2000000000155", kz?.Id, true, unitKg?.Id ?? unitSht.Id),
|
||||
// Хлеб и выпечка
|
||||
("Хлеб «Семейный» нарезной 500г", gBakery, 180, "BAK-BRD-001", "2000000000162", kz?.Id, false, unitSht.Id, false),
|
||||
("Батон «Столичный» 400г", gBakery, 170, "BAK-BRD-002", "2000000000179", kz?.Id, false, unitSht.Id, false),
|
||||
("Лепёшка домашняя", gBakery, 220, "BAK-LEP-001", "2000000000186", kz?.Id, false, unitSht.Id, false),
|
||||
("Круассан шоколадный", gBakery, 320, "BAK-CRO-001", "2000000000193", kz?.Id, false, unitSht.Id, false),
|
||||
("Хлеб «Семейный» нарезной 500г", gBakery, 180, "BAK-BRD-001", "2000000000162", kz?.Id, false, unitSht.Id),
|
||||
("Батон «Столичный» 400г", gBakery, 170, "BAK-BRD-002", "2000000000179", kz?.Id, false, unitSht.Id),
|
||||
("Лепёшка домашняя", gBakery, 220, "BAK-LEP-001", "2000000000186", kz?.Id, false, unitSht.Id),
|
||||
("Круассан шоколадный", gBakery, 320, "BAK-CRO-001", "2000000000193", kz?.Id, false, unitSht.Id),
|
||||
// Кондитерские
|
||||
("Шоколад «Рахат» молочный 100г", gSweets, 650, "SW-CHO-001", "2000000000209", kz?.Id, false, unitSht.Id, false),
|
||||
("Шоколад «Алёнка» 100г", gSweets, 620, "SW-CHO-002", "2000000000216", ru?.Id, false, unitSht.Id, false),
|
||||
("Конфеты «Казахстанские» 200г", gSweets, 890, "SW-CND-001", "2000000000223", kz?.Id, false, unitSht.Id, false),
|
||||
("Печенье «Юбилейное» 250г", gSweets, 540, "SW-CKE-001", "2000000000230", ru?.Id, false, unitSht.Id, false),
|
||||
("Вафли «Артек» 250г", gSweets, 480, "SW-WAF-001", "2000000000247", kz?.Id, false, unitSht.Id, false),
|
||||
("Шоколад «Рахат» молочный 100г", gSweets, 650, "SW-CHO-001", "2000000000209", kz?.Id, false, unitSht.Id),
|
||||
("Шоколад «Алёнка» 100г", gSweets, 620, "SW-CHO-002", "2000000000216", ru?.Id, false, unitSht.Id),
|
||||
("Конфеты «Казахстанские» 200г", gSweets, 890, "SW-CND-001", "2000000000223", kz?.Id, false, unitSht.Id),
|
||||
("Печенье «Юбилейное» 250г", gSweets, 540, "SW-CKE-001", "2000000000230", ru?.Id, false, unitSht.Id),
|
||||
("Вафли «Артек» 250г", gSweets, 480, "SW-WAF-001", "2000000000247", kz?.Id, false, unitSht.Id),
|
||||
// Бакалея
|
||||
("Сахар-песок 1кг", gGrocery, 480, "GRO-SUG-001", "2000000000254", kz?.Id, false, unitSht.Id, false),
|
||||
("Соль поваренная 1кг", gGrocery, 180, "GRO-SLT-001", "2000000000261", kz?.Id, false, unitSht.Id, false),
|
||||
("Рис круглозёрный 1кг", gGrocery, 850, "GRO-RCE-001", "2000000000278", kz?.Id, false, unitSht.Id, false),
|
||||
("Гречка «Мистраль» 900г", gGrocery, 990, "GRO-GRE-001", "2000000000285", ru?.Id, false, unitSht.Id, false),
|
||||
("Макароны «Corona» 500г", gGrocery, 420, "GRO-PAS-001", "2000000000292", kz?.Id, false, unitSht.Id, false),
|
||||
("Масло подсолнечное «Шедевр» 1л", gGrocery, 920, "GRO-OIL-001", "2000000000308", kz?.Id, false, unitL?.Id ?? unitSht.Id, false),
|
||||
("Кофе «Якобс Монарх» 95г растворимый", gGrocery, 2890, "GRO-COF-001", "2000000000315", null, false, unitSht.Id, false),
|
||||
("Сахар-песок 1кг", gGrocery, 480, "GRO-SUG-001", "2000000000254", kz?.Id, false, unitSht.Id),
|
||||
("Соль поваренная 1кг", gGrocery, 180, "GRO-SLT-001", "2000000000261", kz?.Id, false, unitSht.Id),
|
||||
("Рис круглозёрный 1кг", gGrocery, 850, "GRO-RCE-001", "2000000000278", kz?.Id, false, unitSht.Id),
|
||||
("Гречка «Мистраль» 900г", gGrocery, 990, "GRO-GRE-001", "2000000000285", ru?.Id, false, unitSht.Id),
|
||||
("Макароны «Corona» 500г", gGrocery, 420, "GRO-PAS-001", "2000000000292", kz?.Id, false, unitSht.Id),
|
||||
("Масло подсолнечное «Шедевр» 1л", gGrocery, 920, "GRO-OIL-001", "2000000000308", kz?.Id, false, unitL?.Id ?? unitSht.Id),
|
||||
("Кофе «Якобс Монарх» 95г растворимый", gGrocery, 2890, "GRO-COF-001", "2000000000315", null, false, unitSht.Id),
|
||||
// Снеки
|
||||
("Чипсы «Lays» сметана-лук 80г", gSnacks, 380, "SN-CHP-001", "2000000000322", kz?.Id, false, unitSht.Id, false),
|
||||
("Сухарики «3 корочки» ржаные 40г", gSnacks, 190, "SN-SBR-001", "2000000000339", ru?.Id, false, unitSht.Id, false),
|
||||
("Орешки фисташки 100г", gSnacks, 1190, "SN-NUT-001", "2000000000346", kz?.Id, false, unitSht.Id, false),
|
||||
("Семечки «Мартин» 100г", gSnacks, 290, "SN-SED-001", "2000000000353", ru?.Id, false, unitSht.Id, false),
|
||||
("Чипсы «Lays» сметана-лук 80г", gSnacks, 380, "SN-CHP-001", "2000000000322", kz?.Id, false, unitSht.Id),
|
||||
("Сухарики «3 корочки» ржаные 40г", gSnacks, 190, "SN-SBR-001", "2000000000339", ru?.Id, false, unitSht.Id),
|
||||
("Орешки фисташки 100г", gSnacks, 1190, "SN-NUT-001", "2000000000346", kz?.Id, false, unitSht.Id),
|
||||
("Семечки «Мартин» 100г", gSnacks, 290, "SN-SED-001", "2000000000353", ru?.Id, false, unitSht.Id),
|
||||
};
|
||||
|
||||
var products = demo.Select(d =>
|
||||
|
|
@ -159,12 +156,12 @@ Guid AddGroup(string name, Guid? parentId)
|
|||
Name = d.Name,
|
||||
Article = d.Article,
|
||||
UnitOfMeasureId = d.Unit,
|
||||
VatRateId = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")
|
||||
? vat0 : vat,
|
||||
Vat = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")
|
||||
? vat0 : vatDefault,
|
||||
VatEnabled = !(d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")),
|
||||
ProductGroupId = d.Group,
|
||||
CountryOfOriginId = d.Country,
|
||||
IsWeighed = d.IsWeighed,
|
||||
IsAlcohol = d.IsAlcohol,
|
||||
IsActive = true,
|
||||
PurchasePrice = Math.Round(d.RetailPrice * 0.72m, 2),
|
||||
PurchaseCurrencyId = kzt.Id,
|
||||
|
|
|
|||
|
|
@ -78,24 +78,15 @@ public async Task StartAsync(CancellationToken ct)
|
|||
|
||||
private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, CancellationToken ct)
|
||||
{
|
||||
var anyVat = await db.VatRates.IgnoreQueryFilters().AnyAsync(v => v.OrganizationId == orgId, ct);
|
||||
if (!anyVat)
|
||||
{
|
||||
db.VatRates.AddRange(
|
||||
new VatRate { OrganizationId = orgId, Name = "Без НДС", Percent = 0m, IsDefault = false },
|
||||
new VatRate { OrganizationId = orgId, Name = "НДС 12%", Percent = 12m, IsIncludedInPrice = true, IsDefault = true }
|
||||
);
|
||||
}
|
||||
|
||||
var anyUnit = await db.UnitsOfMeasure.IgnoreQueryFilters().AnyAsync(u => u.OrganizationId == orgId, ct);
|
||||
if (!anyUnit)
|
||||
{
|
||||
db.UnitsOfMeasure.AddRange(
|
||||
new UnitOfMeasure { OrganizationId = orgId, Code = "796", Symbol = "шт", Name = "штука", DecimalPlaces = 0, IsBase = true },
|
||||
new UnitOfMeasure { OrganizationId = orgId, Code = "166", Symbol = "кг", Name = "килограмм", DecimalPlaces = 3 },
|
||||
new UnitOfMeasure { OrganizationId = orgId, Code = "112", Symbol = "л", Name = "литр", DecimalPlaces = 3 },
|
||||
new UnitOfMeasure { OrganizationId = orgId, Code = "006", Symbol = "м", Name = "метр", DecimalPlaces = 3 },
|
||||
new UnitOfMeasure { OrganizationId = orgId, Code = "625", Symbol = "уп", Name = "упаковка", DecimalPlaces = 0 }
|
||||
new UnitOfMeasure { OrganizationId = orgId, Code = "796", Name = "штука" },
|
||||
new UnitOfMeasure { OrganizationId = orgId, Code = "166", Name = "килограмм" },
|
||||
new UnitOfMeasure { OrganizationId = orgId, Code = "112", Name = "литр" },
|
||||
new UnitOfMeasure { OrganizationId = orgId, Code = "006", Name = "метр" },
|
||||
new UnitOfMeasure { OrganizationId = orgId, Code = "625", Name = "упаковка" }
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +107,6 @@ private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
|
|||
OrganizationId = orgId,
|
||||
Name = "Основной склад",
|
||||
Code = "MAIN",
|
||||
Kind = StoreKind.Warehouse,
|
||||
IsMain = true,
|
||||
Address = "Алматы, ул. Пример 1",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,17 +6,14 @@ public record CountryDto(Guid Id, string Code, string Name, int SortOrder);
|
|||
|
||||
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol, int MinorUnit, bool IsActive);
|
||||
|
||||
public record VatRateDto(
|
||||
Guid Id, string Name, decimal Percent, bool IsIncludedInPrice, bool IsDefault, bool IsActive);
|
||||
|
||||
public record UnitOfMeasureDto(
|
||||
Guid Id, string Code, string Symbol, string Name, int DecimalPlaces, bool IsBase, bool IsActive);
|
||||
Guid Id, string Code, string Name, string? Description, bool IsActive);
|
||||
|
||||
public record PriceTypeDto(
|
||||
Guid Id, string Name, bool IsDefault, bool IsRetail, int SortOrder, bool IsActive);
|
||||
|
||||
public record StoreDto(
|
||||
Guid Id, string Name, string? Code, StoreKind Kind, string? Address, string? Phone,
|
||||
Guid Id, string Name, string? Code, string? Address, string? Phone,
|
||||
string? ManagerName, bool IsMain, bool IsActive);
|
||||
|
||||
public record RetailPointDto(
|
||||
|
|
@ -27,7 +24,7 @@ public record ProductGroupDto(
|
|||
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, bool IsActive);
|
||||
|
||||
public record CounterpartyDto(
|
||||
Guid Id, string Name, string? LegalName, CounterpartyKind Kind, CounterpartyType Type,
|
||||
Guid Id, string Name, string? LegalName, CounterpartyType Type,
|
||||
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? CountryName,
|
||||
string? Address, string? Phone, string? Email,
|
||||
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive);
|
||||
|
|
@ -38,12 +35,12 @@ public record ProductPriceDto(Guid Id, Guid PriceTypeId, string PriceTypeName, d
|
|||
|
||||
public record ProductDto(
|
||||
Guid Id, string Name, string? Article, string? Description,
|
||||
Guid UnitOfMeasureId, string UnitSymbol,
|
||||
Guid VatRateId, decimal VatPercent,
|
||||
Guid UnitOfMeasureId, string UnitName,
|
||||
int Vat, bool VatEnabled,
|
||||
Guid? ProductGroupId, string? ProductGroupName,
|
||||
Guid? DefaultSupplierId, string? DefaultSupplierName,
|
||||
Guid? CountryOfOriginId, string? CountryOfOriginName,
|
||||
bool IsService, bool IsWeighed, bool IsAlcohol, bool IsMarked,
|
||||
bool IsService, bool IsWeighed, bool IsMarked,
|
||||
decimal? MinStock, decimal? MaxStock,
|
||||
decimal? PurchasePrice, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
|
||||
string? ImageUrl, bool IsActive,
|
||||
|
|
@ -53,11 +50,10 @@ public record ProductDto(
|
|||
// Upsert payloads (input)
|
||||
public record CountryInput(string Code, string Name, int SortOrder = 0);
|
||||
public record CurrencyInput(string Code, string Name, string Symbol, int MinorUnit = 2, bool IsActive = true);
|
||||
public record VatRateInput(string Name, decimal Percent, bool IsIncludedInPrice, bool IsDefault = false, bool IsActive = true);
|
||||
public record UnitOfMeasureInput(string Code, string Symbol, string Name, int DecimalPlaces, bool IsBase = false, bool IsActive = true);
|
||||
public record UnitOfMeasureInput(string Code, string Name, string? Description = null, bool IsActive = true);
|
||||
public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true);
|
||||
public record StoreInput(
|
||||
string Name, string? Code, StoreKind Kind = StoreKind.Warehouse,
|
||||
string Name, string? Code,
|
||||
string? Address = null, string? Phone = null, string? ManagerName = null,
|
||||
bool IsMain = false, bool IsActive = true);
|
||||
public record RetailPointInput(
|
||||
|
|
@ -66,7 +62,7 @@ public record RetailPointInput(
|
|||
string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true);
|
||||
public record ProductGroupInput(string Name, Guid? ParentId, int SortOrder = 0, bool IsActive = true);
|
||||
public record CounterpartyInput(
|
||||
string Name, string? LegalName, CounterpartyKind Kind, CounterpartyType Type,
|
||||
string Name, string? LegalName, CounterpartyType Type,
|
||||
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId,
|
||||
string? Address, string? Phone, string? Email,
|
||||
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive = true);
|
||||
|
|
@ -74,9 +70,9 @@ public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ea
|
|||
public record ProductPriceInput(Guid PriceTypeId, decimal Amount, Guid CurrencyId);
|
||||
public record ProductInput(
|
||||
string Name, string? Article, string? Description,
|
||||
Guid UnitOfMeasureId, Guid VatRateId,
|
||||
Guid UnitOfMeasureId, int Vat, bool VatEnabled,
|
||||
Guid? ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
|
||||
bool IsService = false, bool IsWeighed = false, bool IsAlcohol = false, bool IsMarked = false,
|
||||
bool IsService = false, bool IsWeighed = false, bool IsMarked = false,
|
||||
decimal? MinStock = null, decimal? MaxStock = null,
|
||||
decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null,
|
||||
string? ImageUrl = null, bool IsActive = true,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ public class Counterparty : TenantEntity
|
|||
{
|
||||
public string Name { get; set; } = null!; // отображаемое имя
|
||||
public string? LegalName { get; set; } // полное юридическое имя
|
||||
public CounterpartyKind Kind { get; set; } // Supplier / Customer / Both
|
||||
public CounterpartyType Type { get; set; } // Юрлицо / Физлицо
|
||||
public string? Bin { get; set; } // БИН (для юрлиц РК)
|
||||
public string? Iin { get; set; } // ИИН (для физлиц РК)
|
||||
|
|
|
|||
|
|
@ -1,28 +1,11 @@
|
|||
namespace foodmarket.Domain.Catalog;
|
||||
|
||||
public enum CounterpartyKind
|
||||
{
|
||||
/// <summary>Не указано — дефолт для импортированных без явной классификации.
|
||||
/// MoySklad сам не имеет встроенного поля Supplier/Customer, оно ставится
|
||||
/// через теги или группы, и часто отсутствует. Не выдумываем за пользователя.</summary>
|
||||
Unspecified = 0,
|
||||
Supplier = 1,
|
||||
Customer = 2,
|
||||
Both = 3,
|
||||
}
|
||||
|
||||
public enum CounterpartyType
|
||||
{
|
||||
LegalEntity = 1,
|
||||
Individual = 2,
|
||||
}
|
||||
|
||||
public enum StoreKind
|
||||
{
|
||||
Warehouse = 1,
|
||||
RetailFloor = 2,
|
||||
}
|
||||
|
||||
public enum BarcodeType
|
||||
{
|
||||
Ean13 = 1,
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ public class Product : TenantEntity
|
|||
public Guid UnitOfMeasureId { get; set; }
|
||||
public UnitOfMeasure? UnitOfMeasure { get; set; }
|
||||
|
||||
public Guid VatRateId { get; set; }
|
||||
public VatRate? VatRate { get; set; }
|
||||
// Ставка НДС на товаре — число 0/10/12/16/20 как в MoySklad.
|
||||
// VatEnabled=true → НДС применяется, false → без НДС.
|
||||
public int Vat { get; set; }
|
||||
public bool VatEnabled { get; set; } = true;
|
||||
|
||||
public Guid? ProductGroupId { get; set; }
|
||||
public ProductGroup? ProductGroup { get; set; }
|
||||
|
|
@ -25,7 +27,6 @@ public class Product : TenantEntity
|
|||
|
||||
public bool IsService { get; set; } // услуга, а не физический товар
|
||||
public bool IsWeighed { get; set; } // весовой (продаётся с весов)
|
||||
public bool IsAlcohol { get; set; } // алкоголь (подакцизный)
|
||||
public bool IsMarked { get; set; } // маркируемый (Datamatrix)
|
||||
|
||||
public decimal? MinStock { get; set; } // минимальный остаток (для уведомлений)
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
namespace foodmarket.Domain.Catalog;
|
||||
|
||||
// Склад: физическое место хранения товаров. Может быть чисто склад или торговый зал.
|
||||
// Склад: физическое место хранения товаров. MoySklad не различает "склад" и
|
||||
// "торговый зал" — это одна сущность entity/store, опираемся на это.
|
||||
public class Store : TenantEntity
|
||||
{
|
||||
public string Name { get; set; } = null!;
|
||||
public string? Code { get; set; } // внутренний код склада
|
||||
public StoreKind Kind { get; set; } = StoreKind.Warehouse;
|
||||
public string? Address { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? ManagerName { get; set; }
|
||||
|
|
|
|||
|
|
@ -2,13 +2,11 @@
|
|||
|
||||
namespace foodmarket.Domain.Catalog;
|
||||
|
||||
// Tenant-scoped справочник единиц измерения.
|
||||
// Единица измерения как в MoySklad entity/uom: code + name + description.
|
||||
public class UnitOfMeasure : TenantEntity
|
||||
{
|
||||
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
|
||||
public string Symbol { get; set; } = null!; // "шт", "кг", "л", "м"
|
||||
public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
|
||||
public int DecimalPlaces { get; set; } // 0 для шт, 3 для кг/л
|
||||
public bool IsBase { get; set; } // базовая единица этой организации
|
||||
public string? Description { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -39,14 +39,11 @@ public async Task<MoySkladImportResult> ImportCounterpartiesAsync(string token,
|
|||
{
|
||||
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
|
||||
|
||||
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще — это
|
||||
// не наша выдумка, проверено через API: counterparty entity содержит только
|
||||
// group (группа доступа), tags (произвольные), state (пользовательская цепочка
|
||||
// статусов), companyType (legal/individual/entrepreneur). Никакой role/kind.
|
||||
// Поэтому при импорте ВСЕГДА ставим Unspecified — пользователь сам решит.
|
||||
// Параметр tags оставлен ради совместимости сигнатуры, не используется.
|
||||
static foodmarket.Domain.Catalog.CounterpartyKind ResolveKind(IReadOnlyList<string>? tags)
|
||||
=> foodmarket.Domain.Catalog.CounterpartyKind.Unspecified;
|
||||
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще —
|
||||
// counterparty entity содержит только group (группа доступа), tags
|
||||
// (произвольные), state (пользовательская цепочка статусов), companyType
|
||||
// (legal/individual/entrepreneur). Никакого role/kind. Поэтому у нас тоже
|
||||
// этого поля нет — пусть пользователь сам решит.
|
||||
|
||||
static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType)
|
||||
=> companyType switch
|
||||
|
|
@ -83,7 +80,6 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
|||
OrganizationId = orgId,
|
||||
Name = Trim(c.Name, 255) ?? c.Name,
|
||||
LegalName = Trim(c.LegalTitle, 500),
|
||||
Kind = ResolveKind(c.Tags),
|
||||
Type = ResolveType(c.CompanyType),
|
||||
Bin = Trim(c.Inn, 20),
|
||||
TaxNumber = Trim(c.Kpp, 20),
|
||||
|
|
@ -121,11 +117,10 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
|||
{
|
||||
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
|
||||
|
||||
// Pre-load tenant defaults.
|
||||
var defaultVat = await _db.VatRates.FirstOrDefaultAsync(v => v.IsDefault, ct)
|
||||
?? await _db.VatRates.FirstAsync(ct);
|
||||
var vatByPercent = await _db.VatRates.ToDictionaryAsync(v => (int)v.Percent, v => v.Id, ct);
|
||||
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.IsBase, ct)
|
||||
// Pre-load tenant defaults. KZ default VAT is 16% — applied when product didn't
|
||||
// carry its own vat from MoySklad.
|
||||
const int kzDefaultVat = 16;
|
||||
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.Code == "796", ct)
|
||||
?? await _db.UnitsOfMeasure.FirstAsync(ct);
|
||||
var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct)
|
||||
?? await _db.PriceTypes.FirstAsync(ct);
|
||||
|
|
@ -194,7 +189,8 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
|
|||
|
||||
try
|
||||
{
|
||||
var vatId = p.Vat is int vp && vatByPercent.TryGetValue(vp, out var id) ? id : defaultVat.Id;
|
||||
var vat = p.Vat ?? kzDefaultVat;
|
||||
var vatEnabled = (p.Vat ?? kzDefaultVat) > 0;
|
||||
Guid? groupId = p.ProductFolder?.Meta?.Href is { } href && TryExtractId(href) is { } msGroupId
|
||||
&& localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : null;
|
||||
Guid? countryId = p.Country?.Name is { } cn && countriesByName.TryGetValue(cn, out var cId) ? cId : null;
|
||||
|
|
@ -209,11 +205,11 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
|
|||
Article = Trim(article, 500),
|
||||
Description = p.Description,
|
||||
UnitOfMeasureId = baseUnit.Id,
|
||||
VatRateId = vatId,
|
||||
Vat = vat,
|
||||
VatEnabled = vatEnabled,
|
||||
ProductGroupId = groupId,
|
||||
CountryOfOriginId = countryId,
|
||||
IsWeighed = p.Weighed,
|
||||
IsAlcohol = p.Alcoholic is not null,
|
||||
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
|
||||
IsActive = !p.Archived,
|
||||
PurchasePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
|
|||
|
||||
public DbSet<Country> Countries => Set<Country>();
|
||||
public DbSet<Currency> Currencies => Set<Currency>();
|
||||
public DbSet<VatRate> VatRates => Set<VatRate>();
|
||||
public DbSet<UnitOfMeasure> UnitsOfMeasure => Set<UnitOfMeasure>();
|
||||
public DbSet<Counterparty> Counterparties => Set<Counterparty>();
|
||||
public DbSet<Store> Stores => Set<Store>();
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ public static void ConfigureCatalog(this ModelBuilder b)
|
|||
{
|
||||
b.Entity<Country>(ConfigureCountry);
|
||||
b.Entity<Currency>(ConfigureCurrency);
|
||||
b.Entity<VatRate>(ConfigureVatRate);
|
||||
b.Entity<UnitOfMeasure>(ConfigureUnit);
|
||||
b.Entity<Counterparty>(ConfigureCounterparty);
|
||||
b.Entity<Store>(ConfigureStore);
|
||||
|
|
@ -40,20 +39,12 @@ private static void ConfigureCurrency(EntityTypeBuilder<Currency> b)
|
|||
b.HasIndex(x => x.Code).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureVatRate(EntityTypeBuilder<VatRate> b)
|
||||
{
|
||||
b.ToTable("vat_rates");
|
||||
b.Property(x => x.Name).HasMaxLength(100).IsRequired();
|
||||
b.Property(x => x.Percent).HasPrecision(5, 2);
|
||||
b.HasIndex(x => new { x.OrganizationId, x.Name }).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureUnit(EntityTypeBuilder<UnitOfMeasure> b)
|
||||
{
|
||||
b.ToTable("units_of_measure");
|
||||
b.Property(x => x.Code).HasMaxLength(10).IsRequired();
|
||||
b.Property(x => x.Symbol).HasMaxLength(20).IsRequired();
|
||||
b.Property(x => x.Name).HasMaxLength(100).IsRequired();
|
||||
b.Property(x => x.Description).HasMaxLength(500);
|
||||
b.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique();
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +65,6 @@ private static void ConfigureCounterparty(EntityTypeBuilder<Counterparty> b)
|
|||
b.HasOne(x => x.Country).WithMany().HasForeignKey(x => x.CountryId).OnDelete(DeleteBehavior.Restrict);
|
||||
b.HasIndex(x => new { x.OrganizationId, x.Name });
|
||||
b.HasIndex(x => new { x.OrganizationId, x.Bin });
|
||||
b.HasIndex(x => new { x.OrganizationId, x.Kind });
|
||||
}
|
||||
|
||||
private static void ConfigureStore(EntityTypeBuilder<Store> b)
|
||||
|
|
@ -130,7 +120,6 @@ private static void ConfigureProduct(EntityTypeBuilder<Product> b)
|
|||
b.Property(x => x.ImageUrl).HasMaxLength(1000);
|
||||
|
||||
b.HasOne(x => x.UnitOfMeasure).WithMany().HasForeignKey(x => x.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict);
|
||||
b.HasOne(x => x.VatRate).WithMany().HasForeignKey(x => x.VatRateId).OnDelete(DeleteBehavior.Restrict);
|
||||
b.HasOne(x => x.ProductGroup).WithMany().HasForeignKey(x => x.ProductGroupId).OnDelete(DeleteBehavior.Restrict);
|
||||
b.HasOne(x => x.DefaultSupplier).WithMany().HasForeignKey(x => x.DefaultSupplierId).OnDelete(DeleteBehavior.Restrict);
|
||||
b.HasOne(x => x.CountryOfOrigin).WithMany().HasForeignKey(x => x.CountryOfOriginId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { LoginPage } from '@/pages/LoginPage'
|
|||
import { DashboardPage } from '@/pages/DashboardPage'
|
||||
import { CountriesPage } from '@/pages/CountriesPage'
|
||||
import { CurrenciesPage } from '@/pages/CurrenciesPage'
|
||||
import { VatRatesPage } from '@/pages/VatRatesPage'
|
||||
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
|
||||
import { PriceTypesPage } from '@/pages/PriceTypesPage'
|
||||
import { StoresPage } from '@/pages/StoresPage'
|
||||
|
|
@ -46,7 +45,6 @@ export default function App() {
|
|||
<Route path="/catalog/products/:id" element={<ProductEditPage />} />
|
||||
<Route path="/catalog/product-groups" element={<ProductGroupsPage />} />
|
||||
<Route path="/catalog/units" element={<UnitsOfMeasurePage />} />
|
||||
<Route path="/catalog/vat-rates" element={<VatRatesPage />} />
|
||||
<Route path="/catalog/price-types" element={<PriceTypesPage />} />
|
||||
<Route path="/catalog/counterparties" element={<CounterpartiesPage />} />
|
||||
<Route path="/catalog/stores" element={<StoresPage />} />
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export function ProductPicker({ open, onClose, onPick, title = 'Выбор то
|
|||
<div className="text-xs text-slate-400 flex gap-2 font-mono">
|
||||
{p.article && <span>{p.article}</span>}
|
||||
{p.barcodes[0] && <span>· {p.barcodes[0].code}</span>}
|
||||
<span>· {p.unitSymbol}</span>
|
||||
<span>· {p.unitName}</span>
|
||||
</div>
|
||||
</div>
|
||||
{p.purchasePrice !== null && (
|
||||
|
|
|
|||
|
|
@ -6,25 +6,18 @@ export interface PagedResult<T> {
|
|||
totalPages: number
|
||||
}
|
||||
|
||||
export const CounterpartyKind = { Unspecified: 0, Supplier: 1, Customer: 2, Both: 3 } as const
|
||||
export type CounterpartyKind = (typeof CounterpartyKind)[keyof typeof CounterpartyKind]
|
||||
|
||||
export const CounterpartyType = { LegalEntity: 1, Individual: 2 } as const
|
||||
export type CounterpartyType = (typeof CounterpartyType)[keyof typeof CounterpartyType]
|
||||
|
||||
export const StoreKind = { Warehouse: 1, RetailFloor: 2 } as const
|
||||
export type StoreKind = (typeof StoreKind)[keyof typeof StoreKind]
|
||||
|
||||
export const BarcodeType = { Ean13: 1, Ean8: 2, Code128: 3, Code39: 4, Upca: 5, Upce: 6, Other: 99 } as const
|
||||
export type BarcodeType = (typeof BarcodeType)[keyof typeof BarcodeType]
|
||||
|
||||
export interface Country { id: string; code: string; name: string; sortOrder: number }
|
||||
export interface Currency { id: string; code: string; name: string; symbol: string; minorUnit: number; isActive: boolean }
|
||||
export interface VatRate { id: string; name: string; percent: number; isIncludedInPrice: boolean; isDefault: boolean; isActive: boolean }
|
||||
export interface UnitOfMeasure { id: string; code: string; symbol: string; name: string; decimalPlaces: number; isBase: boolean; isActive: boolean }
|
||||
export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; isActive: boolean }
|
||||
export interface PriceType { id: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean }
|
||||
export interface Store {
|
||||
id: string; name: string; code: string | null; kind: StoreKind; address: string | null; phone: string | null;
|
||||
id: string; name: string; code: string | null; address: string | null; phone: string | null;
|
||||
managerName: string | null; isMain: boolean; isActive: boolean
|
||||
}
|
||||
export interface RetailPoint {
|
||||
|
|
@ -33,7 +26,7 @@ export interface RetailPoint {
|
|||
}
|
||||
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; isActive: boolean }
|
||||
export interface Counterparty {
|
||||
id: string; name: string; legalName: string | null; kind: CounterpartyKind; type: CounterpartyType;
|
||||
id: string; name: string; legalName: string | null; type: CounterpartyType;
|
||||
bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null;
|
||||
address: string | null; phone: string | null; email: string | null;
|
||||
bankName: string | null; bankAccount: string | null; bik: string | null;
|
||||
|
|
@ -43,12 +36,12 @@ export interface ProductBarcode { id: string; code: string; type: BarcodeType; i
|
|||
export interface ProductPrice { id: string; priceTypeId: string; priceTypeName: string; amount: number; currencyId: string; currencyCode: string }
|
||||
export interface Product {
|
||||
id: string; name: string; article: string | null; description: string | null;
|
||||
unitOfMeasureId: string; unitSymbol: string;
|
||||
vatRateId: string; vatPercent: number;
|
||||
unitOfMeasureId: string; unitName: string;
|
||||
vat: number; vatEnabled: boolean;
|
||||
productGroupId: string | null; productGroupName: string | null;
|
||||
defaultSupplierId: string | null; defaultSupplierName: string | null;
|
||||
countryOfOriginId: string | null; countryOfOriginName: string | null;
|
||||
isService: boolean; isWeighed: boolean; isAlcohol: boolean; isMarked: boolean;
|
||||
isService: boolean; isWeighed: boolean; isMarked: boolean;
|
||||
minStock: number | null; maxStock: number | null;
|
||||
purchasePrice: number | null; purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null;
|
||||
imageUrl: string | null; isActive: boolean;
|
||||
|
|
@ -56,7 +49,7 @@ export interface Product {
|
|||
}
|
||||
|
||||
export interface StockRow {
|
||||
productId: string; productName: string; article: string | null; unitSymbol: string;
|
||||
productId: string; productName: string; article: string | null; unitName: string;
|
||||
storeId: string; storeName: string;
|
||||
quantity: number; reservedQuantity: number; available: number;
|
||||
}
|
||||
|
|
@ -83,7 +76,7 @@ export interface SupplyListRow {
|
|||
|
||||
export interface SupplyLineDto {
|
||||
id: string | null; productId: string;
|
||||
productName: string | null; productArticle: string | null; unitSymbol: string | null;
|
||||
productName: string | null; productArticle: string | null; unitName: string | null;
|
||||
quantity: number; unitPrice: number; lineTotal: number; sortOrder: number;
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +109,7 @@ export interface RetailSaleListRow {
|
|||
|
||||
export interface RetailSaleLineDto {
|
||||
id: string | null; productId: string;
|
||||
productName: string | null; productArticle: string | null; unitSymbol: string | null;
|
||||
productName: string | null; productArticle: string | null; unitName: string | null;
|
||||
quantity: number; unitPrice: number; discount: number; lineTotal: number;
|
||||
vatPercent: number; sortOrder: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
PagedResult, UnitOfMeasure, VatRate, ProductGroup, Counterparty,
|
||||
PagedResult, UnitOfMeasure, ProductGroup, Counterparty,
|
||||
Country, Currency, Store, PriceType,
|
||||
} from '@/lib/types'
|
||||
|
||||
|
|
@ -14,7 +14,6 @@ function useLookup<T>(key: string, url: string) {
|
|||
}
|
||||
|
||||
export const useUnits = () => useLookup<UnitOfMeasure>('units', '/api/catalog/units-of-measure')
|
||||
export const useVatRates = () => useLookup<VatRate>('vat', '/api/catalog/vat-rates')
|
||||
export const useProductGroups = () => useLookup<ProductGroup>('groups', '/api/catalog/product-groups')
|
||||
export const useCountries = () => useLookup<Country>('countries', '/api/catalog/countries')
|
||||
export const useCurrencies = () => useLookup<Currency>('currencies', '/api/catalog/currencies')
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Button } from '@/components/Button'
|
|||
import { Modal } from '@/components/Modal'
|
||||
import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field'
|
||||
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
|
||||
import { type Counterparty, type Country, type PagedResult, CounterpartyKind, CounterpartyType } from '@/lib/types'
|
||||
import { type Counterparty, type Country, type PagedResult, CounterpartyType } from '@/lib/types'
|
||||
|
||||
const URL = '/api/catalog/counterparties'
|
||||
|
||||
|
|
@ -18,7 +18,6 @@ interface Form {
|
|||
id?: string
|
||||
name: string
|
||||
legalName: string
|
||||
kind: CounterpartyKind
|
||||
type: CounterpartyType
|
||||
bin: string
|
||||
iin: string
|
||||
|
|
@ -36,20 +35,16 @@ interface Form {
|
|||
}
|
||||
|
||||
const blankForm: Form = {
|
||||
// Kind по умолчанию Unspecified — MoySklad не имеет такого поля у контрагентов,
|
||||
// не выдумываем за пользователя. Пусть выберет вручную если нужно.
|
||||
name: '', legalName: '', kind: CounterpartyKind.Unspecified, type: CounterpartyType.LegalEntity,
|
||||
name: '', legalName: '', type: CounterpartyType.LegalEntity,
|
||||
bin: '', iin: '', taxNumber: '', countryId: '',
|
||||
address: '', phone: '', email: '',
|
||||
bankName: '', bankAccount: '', bik: '',
|
||||
contactPerson: '', notes: '', isActive: true,
|
||||
}
|
||||
|
||||
const kindLabel: Record<CounterpartyKind, string> = {
|
||||
[CounterpartyKind.Unspecified]: '—',
|
||||
[CounterpartyKind.Supplier]: 'Поставщик',
|
||||
[CounterpartyKind.Customer]: 'Покупатель',
|
||||
[CounterpartyKind.Both]: 'Поставщик + Покупатель',
|
||||
const typeLabel: Record<CounterpartyType, string> = {
|
||||
[CounterpartyType.LegalEntity]: 'Юрлицо',
|
||||
[CounterpartyType.Individual]: 'Физлицо',
|
||||
}
|
||||
|
||||
export function CounterpartiesPage() {
|
||||
|
|
@ -92,7 +87,7 @@ export function CounterpartiesPage() {
|
|||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
onRowClick={(r) => setForm({
|
||||
id: r.id, name: r.name, legalName: r.legalName ?? '', kind: r.kind, type: r.type,
|
||||
id: r.id, name: r.name, legalName: r.legalName ?? '', type: r.type,
|
||||
bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '',
|
||||
countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '',
|
||||
bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '',
|
||||
|
|
@ -100,7 +95,7 @@ export function CounterpartiesPage() {
|
|||
})}
|
||||
columns={[
|
||||
{ header: 'Название', cell: (r) => r.name },
|
||||
{ header: 'Тип', width: '120px', cell: (r) => kindLabel[r.kind] },
|
||||
{ header: 'Тип', width: '120px', cell: (r) => typeLabel[r.type] },
|
||||
{ header: 'БИН/ИИН', width: '140px', cell: (r) => <span className="font-mono">{r.bin ?? r.iin ?? '—'}</span> },
|
||||
{ header: 'Телефон', width: '160px', cell: (r) => r.phone ?? '—' },
|
||||
{ header: 'Страна', width: '120px', cell: (r) => r.countryName ?? '—' },
|
||||
|
|
@ -139,14 +134,6 @@ export function CounterpartiesPage() {
|
|||
<Field label="Юридическое название" className="col-span-2">
|
||||
<TextInput value={form.legalName} onChange={(e) => setForm({ ...form, legalName: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Роль">
|
||||
<Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as CounterpartyKind })}>
|
||||
<option value={CounterpartyKind.Unspecified}>Не указано</option>
|
||||
<option value={CounterpartyKind.Supplier}>Поставщик</option>
|
||||
<option value={CounterpartyKind.Customer}>Покупатель</option>
|
||||
<option value={CounterpartyKind.Both}>Поставщик + Покупатель</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Тип лица">
|
||||
<Select value={form.type} onChange={(e) => setForm({ ...form, type: Number(e.target.value) as CounterpartyType })}>
|
||||
<option value={CounterpartyType.LegalEntity}>Юрлицо</option>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { api } from '@/lib/api'
|
|||
import { Button } from '@/components/Button'
|
||||
import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field'
|
||||
import {
|
||||
useUnits, useVatRates, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers,
|
||||
useUnits, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers,
|
||||
} from '@/lib/useLookups'
|
||||
import { BarcodeType, type Product } from '@/lib/types'
|
||||
|
||||
|
|
@ -18,13 +18,13 @@ interface Form {
|
|||
article: string
|
||||
description: string
|
||||
unitOfMeasureId: string
|
||||
vatRateId: string
|
||||
vat: number
|
||||
vatEnabled: boolean
|
||||
productGroupId: string
|
||||
defaultSupplierId: string
|
||||
countryOfOriginId: string
|
||||
isService: boolean
|
||||
isWeighed: boolean
|
||||
isAlcohol: boolean
|
||||
isMarked: boolean
|
||||
isActive: boolean
|
||||
minStock: string
|
||||
|
|
@ -36,11 +36,15 @@ interface Form {
|
|||
barcodes: BarcodeRow[]
|
||||
}
|
||||
|
||||
// KZ default VAT rate.
|
||||
const defaultVat = 16
|
||||
const vatChoices = [0, 10, 12, 16, 20]
|
||||
|
||||
const emptyForm: Form = {
|
||||
name: '', article: '', description: '',
|
||||
unitOfMeasureId: '', vatRateId: '',
|
||||
unitOfMeasureId: '', vat: defaultVat, vatEnabled: true,
|
||||
productGroupId: '', defaultSupplierId: '', countryOfOriginId: '',
|
||||
isService: false, isWeighed: false, isAlcohol: false, isMarked: false, isActive: true,
|
||||
isService: false, isWeighed: false, isMarked: false, isActive: true,
|
||||
minStock: '', maxStock: '',
|
||||
purchasePrice: '', purchaseCurrencyId: '',
|
||||
imageUrl: '',
|
||||
|
|
@ -55,7 +59,6 @@ export function ProductEditPage() {
|
|||
const qc = useQueryClient()
|
||||
|
||||
const units = useUnits()
|
||||
const vats = useVatRates()
|
||||
const groups = useProductGroups()
|
||||
const countries = useCountries()
|
||||
const currencies = useCurrencies()
|
||||
|
|
@ -76,10 +79,10 @@ export function ProductEditPage() {
|
|||
const p = existing.data
|
||||
setForm({
|
||||
name: p.name, article: p.article ?? '', description: p.description ?? '',
|
||||
unitOfMeasureId: p.unitOfMeasureId, vatRateId: p.vatRateId,
|
||||
unitOfMeasureId: p.unitOfMeasureId, vat: p.vat, vatEnabled: p.vatEnabled,
|
||||
productGroupId: p.productGroupId ?? '', defaultSupplierId: p.defaultSupplierId ?? '',
|
||||
countryOfOriginId: p.countryOfOriginId ?? '',
|
||||
isService: p.isService, isWeighed: p.isWeighed, isAlcohol: p.isAlcohol, isMarked: p.isMarked,
|
||||
isService: p.isService, isWeighed: p.isWeighed, isMarked: p.isMarked,
|
||||
isActive: p.isActive,
|
||||
minStock: p.minStock?.toString() ?? '',
|
||||
maxStock: p.maxStock?.toString() ?? '',
|
||||
|
|
@ -93,16 +96,13 @@ export function ProductEditPage() {
|
|||
}, [isNew, existing.data])
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew && form.vatRateId === '' && vats.data?.length) {
|
||||
setForm((f) => ({ ...f, vatRateId: vats.data?.find(v => v.isDefault)?.id ?? vats.data?.[0]?.id ?? '' }))
|
||||
}
|
||||
if (isNew && form.unitOfMeasureId === '' && units.data?.length) {
|
||||
setForm((f) => ({ ...f, unitOfMeasureId: units.data?.find(u => u.isBase)?.id ?? units.data?.[0]?.id ?? '' }))
|
||||
setForm((f) => ({ ...f, unitOfMeasureId: units.data?.[0]?.id ?? '' }))
|
||||
}
|
||||
if (isNew && form.purchaseCurrencyId === '' && currencies.data?.length) {
|
||||
setForm((f) => ({ ...f, purchaseCurrencyId: currencies.data?.find(c => c.code === 'KZT')?.id ?? currencies.data?.[0]?.id ?? '' }))
|
||||
}
|
||||
}, [isNew, vats.data, units.data, currencies.data, form.vatRateId, form.unitOfMeasureId, form.purchaseCurrencyId])
|
||||
}, [isNew, units.data, currencies.data, form.unitOfMeasureId, form.purchaseCurrencyId])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
|
|
@ -111,13 +111,13 @@ export function ProductEditPage() {
|
|||
article: form.article || null,
|
||||
description: form.description || null,
|
||||
unitOfMeasureId: form.unitOfMeasureId,
|
||||
vatRateId: form.vatRateId,
|
||||
vat: form.vat,
|
||||
vatEnabled: form.vatEnabled,
|
||||
productGroupId: form.productGroupId || null,
|
||||
defaultSupplierId: form.defaultSupplierId || null,
|
||||
countryOfOriginId: form.countryOfOriginId || null,
|
||||
isService: form.isService,
|
||||
isWeighed: form.isWeighed,
|
||||
isAlcohol: form.isAlcohol,
|
||||
isMarked: form.isMarked,
|
||||
isActive: form.isActive,
|
||||
minStock: form.minStock === '' ? null : Number(form.minStock),
|
||||
|
|
@ -168,7 +168,7 @@ export function ProductEditPage() {
|
|||
const updateBarcode = (i: number, patch: Partial<BarcodeRow>) =>
|
||||
setForm({ ...form, barcodes: form.barcodes.map((b, ix) => ix === i ? { ...b, ...patch } : b) })
|
||||
|
||||
const canSave = form.name.trim().length > 0 && !!form.unitOfMeasureId && !!form.vatRateId
|
||||
const canSave = form.name.trim().length > 0 && !!form.unitOfMeasureId
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="flex flex-col h-full">
|
||||
|
|
@ -234,13 +234,12 @@ export function ProductEditPage() {
|
|||
<Field label="Единица измерения *">
|
||||
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.symbol} — {u.name}</option>)}
|
||||
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Ставка НДС *">
|
||||
<Select required value={form.vatRateId} onChange={(e) => setForm({ ...form, vatRateId: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{vats.data?.map((v) => <option key={v.id} value={v.id}>{v.name} ({v.percent}%)</option>)}
|
||||
<Field label="Ставка НДС, %">
|
||||
<Select value={form.vat} onChange={(e) => setForm({ ...form, vat: Number(e.target.value) })}>
|
||||
{vatChoices.map((v) => <option key={v} value={v}>{v}%</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Группа">
|
||||
|
|
@ -266,9 +265,9 @@ export function ProductEditPage() {
|
|||
</Field>
|
||||
</Grid>
|
||||
<div className="pt-3 mt-3 border-t border-slate-100 dark:border-slate-800 flex flex-wrap gap-x-6 gap-y-2">
|
||||
<Checkbox label="НДС применяется" checked={form.vatEnabled} onChange={(v) => setForm({ ...form, vatEnabled: v })} />
|
||||
<Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} />
|
||||
<Checkbox label="Весовой" checked={form.isWeighed} onChange={(v) => setForm({ ...form, isWeighed: v })} />
|
||||
<Checkbox label="Алкоголь" checked={form.isAlcohol} onChange={(v) => setForm({ ...form, isAlcohol: v })} />
|
||||
<Checkbox label="Маркируемый" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
|
||||
<Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -45,13 +45,12 @@ export function ProductsPage() {
|
|||
</div>
|
||||
)},
|
||||
{ header: 'Группа', width: '200px', cell: (r) => r.productGroupName ?? '—' },
|
||||
{ header: 'Ед.', width: '70px', cell: (r) => r.unitSymbol },
|
||||
{ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vatPercent}%` },
|
||||
{ header: 'Ед.', width: '70px', cell: (r) => r.unitName },
|
||||
{ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vat}%` },
|
||||
{ header: 'Тип', width: '140px', cell: (r) => (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{r.isService && <span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-700">Услуга</span>}
|
||||
{r.isWeighed && <span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Весовой</span>}
|
||||
{r.isAlcohol && <span className="text-xs px-1.5 py-0.5 rounded bg-red-50 text-red-700">Алкоголь</span>}
|
||||
{r.isMarked && <span className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">Маркир.</span>}
|
||||
</div>
|
||||
)},
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ interface LineRow {
|
|||
productId: string
|
||||
productName: string
|
||||
productArticle: string | null
|
||||
unitSymbol: string | null
|
||||
unitName: string | null
|
||||
quantity: number
|
||||
unitPrice: number
|
||||
discount: number
|
||||
|
|
@ -81,7 +81,7 @@ export function RetailSaleEditPage() {
|
|||
productId: l.productId,
|
||||
productName: l.productName ?? '',
|
||||
productArticle: l.productArticle,
|
||||
unitSymbol: l.unitSymbol,
|
||||
unitName: l.unitName,
|
||||
quantity: l.quantity, unitPrice: l.unitPrice, discount: l.discount,
|
||||
vatPercent: l.vatPercent,
|
||||
})),
|
||||
|
|
@ -179,11 +179,11 @@ export function RetailSaleEditPage() {
|
|||
productId: p.id,
|
||||
productName: p.name,
|
||||
productArticle: p.article,
|
||||
unitSymbol: p.unitSymbol,
|
||||
unitName: p.unitName,
|
||||
quantity: 1,
|
||||
unitPrice: retail?.amount ?? 0,
|
||||
discount: 0,
|
||||
vatPercent: p.vatPercent,
|
||||
vatPercent: p.vat * 1,
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
|
@ -323,7 +323,7 @@ export function RetailSaleEditPage() {
|
|||
<div className="font-medium">{l.productName}</div>
|
||||
{l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-slate-500">{l.unitSymbol}</td>
|
||||
<td className="py-2 px-3 text-slate-500">{l.unitName}</td>
|
||||
<td className="py-2 px-3">
|
||||
<TextInput type="number" step="0.001" disabled={isPosted}
|
||||
className="text-right font-mono" value={l.quantity}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export function StockPage() {
|
|||
</div>
|
||||
)},
|
||||
{ header: 'Склад', width: '220px', cell: (r) => r.storeName },
|
||||
{ header: 'Ед.', width: '80px', cell: (r) => r.unitSymbol },
|
||||
{ header: 'Ед.', width: '80px', cell: (r) => r.unitName },
|
||||
{ header: 'Остаток', width: '130px', className: 'text-right font-mono', cell: (r) => r.quantity.toLocaleString('ru', { maximumFractionDigits: 3 }) },
|
||||
{ header: 'Резерв', width: '110px', className: 'text-right font-mono text-slate-400', cell: (r) => r.reservedQuantity ? r.reservedQuantity.toLocaleString('ru') : '—' },
|
||||
{ header: 'Доступно', width: '130px', className: 'text-right font-mono font-semibold', cell: (r) => (
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import { Pagination } from '@/components/Pagination'
|
|||
import { SearchBar } from '@/components/SearchBar'
|
||||
import { Button } from '@/components/Button'
|
||||
import { Modal } from '@/components/Modal'
|
||||
import { Field, TextInput, Select, Checkbox } from '@/components/Field'
|
||||
import { Field, TextInput, Checkbox } from '@/components/Field'
|
||||
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
|
||||
import { type Store, StoreKind } from '@/lib/types'
|
||||
import { type Store } from '@/lib/types'
|
||||
|
||||
const URL = '/api/catalog/stores'
|
||||
|
||||
|
|
@ -16,7 +16,6 @@ interface Form {
|
|||
id?: string
|
||||
name: string
|
||||
code: string
|
||||
kind: StoreKind
|
||||
address: string
|
||||
phone: string
|
||||
managerName: string
|
||||
|
|
@ -25,7 +24,7 @@ interface Form {
|
|||
}
|
||||
|
||||
const blankForm: Form = {
|
||||
name: '', code: '', kind: StoreKind.Warehouse, address: '', phone: '',
|
||||
name: '', code: '', address: '', phone: '',
|
||||
managerName: '', isMain: false, isActive: true,
|
||||
}
|
||||
|
||||
|
|
@ -62,14 +61,13 @@ export function StoresPage() {
|
|||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
onRowClick={(r) => setForm({
|
||||
id: r.id, name: r.name, code: r.code ?? '', kind: r.kind,
|
||||
id: r.id, name: r.name, code: r.code ?? '',
|
||||
address: r.address ?? '', phone: r.phone ?? '', managerName: r.managerName ?? '',
|
||||
isMain: r.isMain, isActive: r.isActive,
|
||||
})}
|
||||
columns={[
|
||||
{ header: 'Название', cell: (r) => r.name },
|
||||
{ header: 'Код', width: '120px', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> },
|
||||
{ header: 'Тип', width: '150px', cell: (r) => r.kind === StoreKind.Warehouse ? 'Склад' : 'Торговый зал' },
|
||||
{ header: 'Адрес', cell: (r) => r.address ?? '—' },
|
||||
{ header: 'Основной', width: '110px', cell: (r) => r.isMain ? '✓' : '—' },
|
||||
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
||||
|
|
@ -108,12 +106,6 @@ export function StoresPage() {
|
|||
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Тип">
|
||||
<Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as StoreKind })}>
|
||||
<option value={StoreKind.Warehouse}>Склад</option>
|
||||
<option value={StoreKind.RetailFloor}>Торговый зал</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Адрес">
|
||||
<TextInput value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
||||
</Field>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ interface LineRow {
|
|||
productId: string
|
||||
productName: string
|
||||
productArticle: string | null
|
||||
unitSymbol: string | null
|
||||
unitName: string | null
|
||||
quantity: number
|
||||
unitPrice: number
|
||||
}
|
||||
|
|
@ -76,7 +76,7 @@ export function SupplyEditPage() {
|
|||
productId: l.productId,
|
||||
productName: l.productName ?? '',
|
||||
productArticle: l.productArticle,
|
||||
unitSymbol: l.unitSymbol,
|
||||
unitName: l.unitName,
|
||||
quantity: l.quantity,
|
||||
unitPrice: l.unitPrice,
|
||||
})),
|
||||
|
|
@ -169,7 +169,7 @@ export function SupplyEditPage() {
|
|||
productId: p.id,
|
||||
productName: p.name,
|
||||
productArticle: p.article,
|
||||
unitSymbol: p.unitSymbol,
|
||||
unitName: p.unitName,
|
||||
quantity: 1,
|
||||
unitPrice: p.purchasePrice ?? 0,
|
||||
}],
|
||||
|
|
@ -304,7 +304,7 @@ export function SupplyEditPage() {
|
|||
<div className="font-medium">{l.productName}</div>
|
||||
{l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-slate-500">{l.unitSymbol}</td>
|
||||
<td className="py-2 px-3 text-slate-500">{l.unitName}</td>
|
||||
<td className="py-2 px-3">
|
||||
<TextInput type="number" step="0.001" disabled={isPosted}
|
||||
className="text-right font-mono"
|
||||
|
|
|
|||
|
|
@ -15,14 +15,12 @@ const URL = '/api/catalog/units-of-measure'
|
|||
interface Form {
|
||||
id?: string
|
||||
code: string
|
||||
symbol: string
|
||||
name: string
|
||||
decimalPlaces: number
|
||||
isBase: boolean
|
||||
description: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const blankForm: Form = { code: '', symbol: '', name: '', decimalPlaces: 0, isBase: false, isActive: true }
|
||||
const blankForm: Form = { code: '', name: '', description: '', isActive: true }
|
||||
|
||||
export function UnitsOfMeasurePage() {
|
||||
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<UnitOfMeasure>(URL)
|
||||
|
|
@ -41,7 +39,7 @@ export function UnitsOfMeasurePage() {
|
|||
<>
|
||||
<ListPageShell
|
||||
title="Единицы измерения"
|
||||
description="Код по ОКЕИ (шт=796, кг=166, л=112, м=006)."
|
||||
description="Код по ОКЕИ (штука=796, килограмм=166, литр=112, метр=006)."
|
||||
actions={
|
||||
<>
|
||||
<SearchBar value={search} onChange={setSearch} />
|
||||
|
|
@ -56,13 +54,14 @@ export function UnitsOfMeasurePage() {
|
|||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
onRowClick={(r) => setForm({ ...r })}
|
||||
onRowClick={(r) => setForm({
|
||||
id: r.id, code: r.code, name: r.name,
|
||||
description: r.description ?? '', isActive: r.isActive,
|
||||
})}
|
||||
columns={[
|
||||
{ header: 'Код', width: '90px', cell: (r) => <span className="font-mono">{r.code}</span> },
|
||||
{ header: 'Символ', width: '100px', cell: (r) => r.symbol },
|
||||
{ header: 'Название', cell: (r) => r.name },
|
||||
{ header: 'Дробность', width: '120px', className: 'text-right', cell: (r) => r.decimalPlaces },
|
||||
{ header: 'Базовая', width: '100px', cell: (r) => r.isBase ? '✓' : '—' },
|
||||
{ header: 'Описание', cell: (r) => r.description ?? '—' },
|
||||
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
||||
]}
|
||||
/>
|
||||
|
|
@ -91,25 +90,15 @@ export function UnitsOfMeasurePage() {
|
|||
>
|
||||
{form && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Код ОКЕИ">
|
||||
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Символ">
|
||||
<TextInput value={form.symbol} onChange={(e) => setForm({ ...form, symbol: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Код ОКЕИ">
|
||||
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Название">
|
||||
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Количество знаков после запятой">
|
||||
<TextInput
|
||||
type="number" min="0" max="6"
|
||||
value={form.decimalPlaces}
|
||||
onChange={(e) => setForm({ ...form, decimalPlaces: Number(e.target.value) })}
|
||||
/>
|
||||
<Field label="Описание">
|
||||
<TextInput value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
</Field>
|
||||
<Checkbox label="Базовая единица организации" checked={form.isBase} onChange={(v) => setForm({ ...form, isBase: v })} />
|
||||
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue