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
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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)
|
.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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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; } // ИИН (для физлиц РК)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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; } // минимальный остаток (для уведомлений)
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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>();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)},
|
)},
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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) => (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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