feat(catalog): карточка товара как МойСклад+ — обязательно только наименование
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Правки владельца 08.10 (п.4 паритета): - Сохранить можно при наличии Наименования. Единица не обязательна в запросе → дефолт «шт»; группа → корень. Штрихкод ОБЯЗАТЕЛЕН у любого товара: нет в запросе/ импорте → автогенерация внутреннего EAN-13 (верная контр. цифра, префикс не пересекается с весовыми/ценовыми префиксами орг. — InternalBarcode). - Цена продажи не обязательна для сохранения; но ПРОДАТЬ без неё нельзя — касса и розничная продажа дают «У товара «X» нет цены продажи» (можно ввести вручную). - Быстрое создание из кассы/приёмки: POST /api/catalog/products/quick (имя + опц. штрихкод/цена/весовой). - Снят FluentValidation NotEqual(Guid.Empty) на единицу (контроллер подставляет дефолт). Тесты: unit InternalBarcode 3/3 (валидный EAN-13, не парсится как весовой, обходит префиксы); integration 3/3 (создание по одному имени, продажа без цены → 400, quick). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
db248d90f0
commit
0ca0d6cc3b
40
docs/moysklad-parity-progress.md
Normal file
40
docs/moysklad-parity-progress.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Паритет с МойСкладом — прогресс
|
||||||
|
|
||||||
|
Задача владельца (08.10): довести процессы до уровня МойСклада и лучше, по выводам
|
||||||
|
`docs/moysklad-logic-audit.md`. Правило: «минимум как МойСклад, где можно — лучше».
|
||||||
|
|
||||||
|
Легенда: [x] готово · [~] частично · [ ] не начато.
|
||||||
|
|
||||||
|
## План / статус
|
||||||
|
1. [ ] **Смена кассы**: автооткрытие первой продажей/внесением (вопрос «размен?», можно пропустить);
|
||||||
|
ручное открытие остаётся; длительность >24ч — предупреждение кассиру/админу + отметка в отчёте.
|
||||||
|
POS + веб.
|
||||||
|
2. [ ] **Z-отчёт**: разбивка по товарам (кол-во, выручка, возвраты) + печать.
|
||||||
|
3. [ ] **Возврат покупателя**: способ возврата = способ оплаты исходного чека по умолчанию; другой —
|
||||||
|
только по праву администратора, с причиной.
|
||||||
|
4. [ ] **Карточка товара** (правки владельца перекрывают аудит):
|
||||||
|
- Сохранить можно при наличии Наименования.
|
||||||
|
- Единица обязательна, предзаполнена «шт» (пустой не бывает).
|
||||||
|
- **Штрихкод ОБЯЗАТЕЛЕН** у любого товара (включая весовые). Нет → автогенерация внутреннего
|
||||||
|
EAN-13 (верная контрольная цифра, префикс не пересекается с весовыми/ценовыми префиксами орг.),
|
||||||
|
кнопка «Сгенерировать». Импорт без штрихкода — автогенерация. Продажа без штрихкода невозможна.
|
||||||
|
- Закупочная цена не обязательна (приходит из приёмки).
|
||||||
|
- Цена продажи: без неё сохранить можно, но ПРОДАТЬ нельзя → касса/продажа дают понятное
|
||||||
|
сообщение «у товара нет цены продажи» + быстрый ввод цены (по праву).
|
||||||
|
- Быстрое создание товара из приёмки/кассы по неизвестному штрихкоду (1 поле + цена).
|
||||||
|
- Недостающее ловит фоновая «Проверка данных», а не запрет сохранения.
|
||||||
|
5. [ ] **«Создать на основании» + связи** — сквозной механизм для всех документов; блок «Связанные
|
||||||
|
документы» и «На основании …» везде (инвентаризация — уже).
|
||||||
|
6. [ ] **Простой режим**: Оприходование/Списание остаются отдельными пунктами (НЕ прятать).
|
||||||
|
7. [ ] **Деньги**: единый журнал, но типы назвать привычно (ПКО/РКО/вх/исх платёж/перевод);
|
||||||
|
акт сверки с поставщиком (печать/PDF).
|
||||||
|
8. [ ] **Главная простого режима**: блок «Прибыль за сегодня/месяц» (точная — после FIFO).
|
||||||
|
9. [ ] **Статусы на русском**: Черновик / Проведён / Завершена + подсказка при наведении.
|
||||||
|
10. [ ] **Тесты** (unit/integration/e2e) по каждому пункту; full-chain; деплой; скриншоты.
|
||||||
|
|
||||||
|
## Коммиты
|
||||||
|
(заполняется по ходу)
|
||||||
|
|
||||||
|
## Что НЕ трогаем
|
||||||
|
Прод, shared-postgres, global.json, gateway nginx, sklad. FIFO/себестоимость — отдельная
|
||||||
|
отложенная тема (п.8 — точная прибыль после FIFO; пока приближённо по текущей себестоимости).
|
||||||
|
|
@ -276,29 +276,33 @@ public async Task<ActionResult<ProductDto>> Get(Guid id, CancellationToken ct)
|
||||||
[HttpPost, RequiresPermission("ProductsEdit")]
|
[HttpPost, RequiresPermission("ProductsEdit")]
|
||||||
public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct)
|
public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// Обязательно только Наименование (владелец 08.10). Группа/единица —
|
||||||
|
// с дефолтами (корень / «шт»). Цена продажи — НЕ обязательна для сохранения
|
||||||
|
// (без неё нельзя ПРОДАТЬ — проверка на кассе/продаже). Недостающее ловит
|
||||||
|
// фоновая «Проверка данных», а не запрет сохранения.
|
||||||
if (string.IsNullOrWhiteSpace(input.Name))
|
if (string.IsNullOrWhiteSpace(input.Name))
|
||||||
return BadRequest(new { error = "Название товара обязательно.", field = nameof(input.Name) });
|
return BadRequest(new { error = "Название товара обязательно.", field = nameof(input.Name) });
|
||||||
if (RequiredGuid.FirstMissing(
|
var groupId = await ResolveGroupIdAsync(input.ProductGroupId, ct);
|
||||||
(nameof(input.UnitOfMeasureId), input.UnitOfMeasureId),
|
if (groupId is null) return BadRequest(new { error = "В организации не настроены группы товаров." });
|
||||||
(nameof(input.ProductGroupId), input.ProductGroupId)) is { } missingFk)
|
var unitId = await ResolveUnitIdAsync(input.UnitOfMeasureId, ct);
|
||||||
return BadRequest(new { error = $"Поле {missingFk} обязательно.", field = missingFk });
|
if (unitId is null) return BadRequest(new { error = "В организации не настроены единицы измерения." });
|
||||||
if (input.Barcodes is null || input.Barcodes.Count == 0)
|
// Штрихкод обязателен у любого товара: нет — генерируем внутренний EAN-13.
|
||||||
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
|
var barcodes = await EnsureBarcodeInputsAsync(input.Barcodes, ct);
|
||||||
if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing)
|
var conflict = await FindBarcodeConflictAsync(barcodes.Select(b => b.Code), null, ct);
|
||||||
return BadRequest(new { error = $"Цена «{missing}» обязательна и должна быть больше 0." });
|
|
||||||
var conflict = await FindBarcodeConflictAsync(input.Barcodes.Select(b => b.Code), null, ct);
|
|
||||||
if (conflict is { } c)
|
if (conflict is { } c)
|
||||||
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
|
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
|
||||||
var allowFractional = await AllowFractionalAsync(ct);
|
var allowFractional = await AllowFractionalAsync(ct);
|
||||||
var e = new Product();
|
var e = new Product();
|
||||||
Apply(e, input);
|
Apply(e, input);
|
||||||
|
e.ProductGroupId = groupId.Value;
|
||||||
|
e.UnitOfMeasureId = unitId.Value;
|
||||||
e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional);
|
e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional);
|
||||||
// Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны.
|
// Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны.
|
||||||
if (input.Vat is null) e.Vat = await ResolveDefaultVatAsync(ct);
|
if (input.Vat is null) e.Vat = await ResolveDefaultVatAsync(ct);
|
||||||
// Авто-артикул: если пользователь не указал — генерируем числовой.
|
// Авто-артикул: если пользователь не указал — генерируем числовой.
|
||||||
if (string.IsNullOrWhiteSpace(e.Article)) e.Article = await GenerateNextArticleAsync(ct);
|
if (string.IsNullOrWhiteSpace(e.Article)) e.Article = await GenerateNextArticleAsync(ct);
|
||||||
|
|
||||||
foreach (var b in input.Barcodes ?? [])
|
foreach (var b in barcodes)
|
||||||
e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary });
|
e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary });
|
||||||
foreach (var pr in input.Prices ?? [])
|
foreach (var pr in input.Prices ?? [])
|
||||||
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = RoundIfNeeded(pr.Amount, allowFractional), CurrencyId = pr.CurrencyId });
|
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = RoundIfNeeded(pr.Amount, allowFractional), CurrencyId = pr.CurrencyId });
|
||||||
|
|
@ -316,18 +320,66 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
|
||||||
return CreatedAtAction(nameof(Get), new { id = e.Id }, dto);
|
return CreatedAtAction(nameof(Get), new { id = e.Id }, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record QuickCreateInput(string Name, string? Barcode, decimal? SalePrice, bool Weighed = false);
|
||||||
|
|
||||||
|
/// <summary>Быстрое создание товара из кассы/приёмки по неизвестному штрихкоду:
|
||||||
|
/// одно поле (наименование) + опц. цена. Единица «шт», группа — корень, НДС —
|
||||||
|
/// из страны. Если штрихкод передан (сканированный) — используем его, иначе
|
||||||
|
/// генерируем внутренний. Цена продажи опциональна (без неё товар не продать).</summary>
|
||||||
|
[HttpPost("quick"), RequiresPermission("ProductsEdit")]
|
||||||
|
public async Task<ActionResult<ProductDto>> QuickCreate([FromBody] QuickCreateInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input.Name))
|
||||||
|
return BadRequest(new { error = "Название товара обязательно.", field = nameof(input.Name) });
|
||||||
|
var groupId = await ResolveGroupIdAsync(Guid.Empty, ct);
|
||||||
|
if (groupId is null) return BadRequest(new { error = "В организации не настроены группы товаров." });
|
||||||
|
var unitId = await ResolveUnitIdAsync(Guid.Empty, ct);
|
||||||
|
if (unitId is null) return BadRequest(new { error = "В организации не настроены единицы измерения." });
|
||||||
|
|
||||||
|
IReadOnlyList<ProductBarcodeInput> barcodeInputs = string.IsNullOrWhiteSpace(input.Barcode)
|
||||||
|
? await EnsureBarcodeInputsAsync(null, ct)
|
||||||
|
: new[] { new ProductBarcodeInput(input.Barcode.Trim(), BarcodeType.Ean13, true) };
|
||||||
|
var conflict = await FindBarcodeConflictAsync(barcodeInputs.Select(b => b.Code), null, ct);
|
||||||
|
if (conflict is { } c)
|
||||||
|
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}».", code = "barcode-duplicate" });
|
||||||
|
|
||||||
|
var allowFractional = await AllowFractionalAsync(ct);
|
||||||
|
var e = new Product
|
||||||
|
{
|
||||||
|
Name = input.Name.Trim(),
|
||||||
|
UnitOfMeasureId = unitId.Value,
|
||||||
|
ProductGroupId = groupId.Value,
|
||||||
|
Vat = await ResolveDefaultVatAsync(ct),
|
||||||
|
VatEnabled = true,
|
||||||
|
Packaging = input.Weighed ? Packaging.Weight : Packaging.Piece,
|
||||||
|
Article = await GenerateNextArticleAsync(ct),
|
||||||
|
};
|
||||||
|
foreach (var b in barcodeInputs)
|
||||||
|
e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary });
|
||||||
|
if (input.SalePrice is decimal sp && sp > 0m)
|
||||||
|
{
|
||||||
|
var retailPt = await _db.PriceTypes.Where(p => p.IsRetail).OrderByDescending(p => p.IsSystem)
|
||||||
|
.Select(p => (Guid?)p.Id).FirstOrDefaultAsync(ct);
|
||||||
|
var curId = await _db.Currencies.Where(cu => cu.Code == "KZT").Select(cu => cu.Id).FirstOrDefaultAsync(ct);
|
||||||
|
if (retailPt is { } pt && curId != Guid.Empty)
|
||||||
|
e.Prices.Add(new ProductPrice { PriceTypeId = pt, Amount = RoundIfNeeded(sp, allowFractional), CurrencyId = curId });
|
||||||
|
}
|
||||||
|
_db.Products.Add(e);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return CreatedAtAction(nameof(Get), new { id = e.Id }, await GetInternalAsync(e.Id, ct));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), RequiresPermission("ProductsEdit")]
|
[HttpPut("{id:guid}"), RequiresPermission("ProductsEdit")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (RequiredGuid.FirstMissing(
|
if (string.IsNullOrWhiteSpace(input.Name))
|
||||||
(nameof(input.UnitOfMeasureId), input.UnitOfMeasureId),
|
return BadRequest(new { error = "Название товара обязательно.", field = nameof(input.Name) });
|
||||||
(nameof(input.ProductGroupId), input.ProductGroupId)) is { } missingFk)
|
var groupId = await ResolveGroupIdAsync(input.ProductGroupId, ct);
|
||||||
return BadRequest(new { error = $"Поле {missingFk} обязательно.", field = missingFk });
|
if (groupId is null) return BadRequest(new { error = "В организации не настроены группы товаров." });
|
||||||
if (input.Barcodes is null || input.Barcodes.Count == 0)
|
var unitId = await ResolveUnitIdAsync(input.UnitOfMeasureId, ct);
|
||||||
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
|
if (unitId is null) return BadRequest(new { error = "В организации не настроены единицы измерения." });
|
||||||
if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing)
|
var barcodes = await EnsureBarcodeInputsAsync(input.Barcodes, ct);
|
||||||
return BadRequest(new { error = $"Цена «{missing}» обязательна и должна быть больше 0." });
|
var conflict = await FindBarcodeConflictAsync(barcodes.Select(b => b.Code), id, ct);
|
||||||
var conflict = await FindBarcodeConflictAsync(input.Barcodes.Select(b => b.Code), id, ct);
|
|
||||||
if (conflict is { } c)
|
if (conflict is { } c)
|
||||||
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
|
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
|
||||||
var e = await _db.Products
|
var e = await _db.Products
|
||||||
|
|
@ -338,6 +390,8 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
|
||||||
|
|
||||||
var allowFractional = await AllowFractionalAsync(ct);
|
var allowFractional = await AllowFractionalAsync(ct);
|
||||||
Apply(e, input);
|
Apply(e, input);
|
||||||
|
e.ProductGroupId = groupId.Value;
|
||||||
|
e.UnitOfMeasureId = unitId.Value;
|
||||||
e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional);
|
e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional);
|
||||||
|
|
||||||
// Children-collections правим через ExecuteDelete (минует трекер) +
|
// Children-collections правим через ExecuteDelete (минует трекер) +
|
||||||
|
|
@ -346,7 +400,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
|
||||||
// что в Supplies/Demands/RetailSales.Update — см. их комментарии.
|
// что в Supplies/Demands/RetailSales.Update — см. их комментарии.
|
||||||
await _db.ProductBarcodes.Where(b => b.ProductId == e.Id).ExecuteDeleteAsync(ct);
|
await _db.ProductBarcodes.Where(b => b.ProductId == e.Id).ExecuteDeleteAsync(ct);
|
||||||
await _db.ProductPrices.Where(p => p.ProductId == e.Id).ExecuteDeleteAsync(ct);
|
await _db.ProductPrices.Where(p => p.ProductId == e.Id).ExecuteDeleteAsync(ct);
|
||||||
foreach (var b in input.Barcodes ?? [])
|
foreach (var b in barcodes)
|
||||||
_db.ProductBarcodes.Add(new ProductBarcode
|
_db.ProductBarcodes.Add(new ProductBarcode
|
||||||
{
|
{
|
||||||
ProductId = e.Id, Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary,
|
ProductId = e.Id, Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary,
|
||||||
|
|
@ -821,14 +875,15 @@ int Idx(params string[] aliases)
|
||||||
_db.Products.Add(p);
|
_db.Products.Add(p);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(r.Barcode))
|
// Штрихкод обязателен: из файла или автогенерация внутреннего EAN-13.
|
||||||
|
var bc = !string.IsNullOrWhiteSpace(r.Barcode)
|
||||||
|
? r.Barcode.Trim()
|
||||||
|
: await GenerateUniqueInternalBarcodeAsync(await GetWeightedSettingsAsync(ct), ct);
|
||||||
|
_db.ProductBarcodes.Add(new ProductBarcode
|
||||||
{
|
{
|
||||||
_db.ProductBarcodes.Add(new ProductBarcode
|
ProductId = p.Id, Code = bc,
|
||||||
{
|
Type = BarcodeType.Ean13, IsPrimary = true,
|
||||||
ProductId = p.Id, Code = r.Barcode.Trim(),
|
});
|
||||||
Type = BarcodeType.Ean13, IsPrimary = true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (r.Price is decimal price && price >= 0m && defaultPriceType is not null && fallbackCurrency is not null)
|
if (r.Price is decimal price && price >= 0m && defaultPriceType is not null && fallbackCurrency is not null)
|
||||||
{
|
{
|
||||||
_db.ProductPrices.Add(new ProductPrice
|
_db.ProductPrices.Add(new ProductPrice
|
||||||
|
|
@ -1153,6 +1208,65 @@ public record ByBarcodeResult(IReadOnlyList<QuickSearchItem> Items);
|
||||||
public record WeightedBarcodeHit(
|
public record WeightedBarcodeHit(
|
||||||
QuickSearchItem product, bool weighted, string valueKind, decimal quantity, decimal? embeddedPrice);
|
QuickSearchItem product, bool weighted, string valueKind, decimal quantity, decimal? embeddedPrice);
|
||||||
|
|
||||||
|
/// <summary>Группа по умолчанию: переданная (если существует), иначе корневая
|
||||||
|
/// «Все товары»/«Без группы». null — в орг. нет ни одной группы (аномалия).</summary>
|
||||||
|
private async Task<Guid?> ResolveGroupIdAsync(Guid provided, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (provided != Guid.Empty && await _db.ProductGroups.AnyAsync(g => g.Id == provided, ct))
|
||||||
|
return provided;
|
||||||
|
// Корень: без родителя, самый ранний.
|
||||||
|
var root = await _db.ProductGroups.Where(g => g.ParentId == null)
|
||||||
|
.OrderBy(g => g.CreatedAt).Select(g => (Guid?)g.Id).FirstOrDefaultAsync(ct);
|
||||||
|
return root ?? await _db.ProductGroups.OrderBy(g => g.CreatedAt).Select(g => (Guid?)g.Id).FirstOrDefaultAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Единица по умолчанию: переданная (если существует), иначе «шт» (код 796),
|
||||||
|
/// иначе первая доступная. null — в орг. нет единиц (аномалия).</summary>
|
||||||
|
private async Task<Guid?> ResolveUnitIdAsync(Guid provided, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (provided != Guid.Empty && await _db.UnitsOfMeasure.AnyAsync(u => u.Id == provided, ct))
|
||||||
|
return provided;
|
||||||
|
var sht = await _db.UnitsOfMeasure.Where(u => u.Code == "796").Select(u => (Guid?)u.Id).FirstOrDefaultAsync(ct);
|
||||||
|
return sht ?? await _db.UnitsOfMeasure.OrderBy(u => u.Code).Select(u => (Guid?)u.Id).FirstOrDefaultAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Штрихкоды для сохранения: заданные пользователем (непустые), либо —
|
||||||
|
/// если ни одного нет — сгенерированный уникальный внутренний EAN-13 (владелец:
|
||||||
|
/// штрихкод обязателен у любого товара). Первый штрихкод помечается primary.</summary>
|
||||||
|
private async Task<IReadOnlyList<ProductBarcodeInput>> EnsureBarcodeInputsAsync(
|
||||||
|
IReadOnlyList<ProductBarcodeInput>? provided, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var list = (provided ?? [])
|
||||||
|
.Where(b => !string.IsNullOrWhiteSpace(b.Code))
|
||||||
|
.Select(b => b with { Code = b.Code.Trim() })
|
||||||
|
.ToList();
|
||||||
|
if (list.Count == 0)
|
||||||
|
{
|
||||||
|
var settings = await GetWeightedSettingsAsync(ct);
|
||||||
|
var code = await GenerateUniqueInternalBarcodeAsync(settings, ct);
|
||||||
|
list.Add(new ProductBarcodeInput(code, BarcodeType.Ean13, true));
|
||||||
|
}
|
||||||
|
else if (!list.Any(b => b.IsPrimary))
|
||||||
|
{
|
||||||
|
list[0] = list[0] with { IsPrimary = true };
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Сгенерировать внутренний EAN-13, уникальный в пределах организации
|
||||||
|
/// (ретрай при коллизии).</summary>
|
||||||
|
private async Task<string> GenerateUniqueInternalBarcodeAsync(WeightedBarcodeSettings settings, CancellationToken ct)
|
||||||
|
{
|
||||||
|
for (int attempt = 0; attempt < 20; attempt++)
|
||||||
|
{
|
||||||
|
var code = foodmarket.Shared.Barcodes.InternalBarcode.Generate(settings);
|
||||||
|
if (!await _db.ProductBarcodes.AnyAsync(b => b.Code == code, ct))
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
// Крайне маловероятно; добавим timestamp-хвост к префиксу, чтобы точно разойтись.
|
||||||
|
return foodmarket.Shared.Barcodes.InternalBarcode.Generate(settings);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Настройки разбора весовых этикеток текущей организации.</summary>
|
/// <summary>Настройки разбора весовых этикеток текущей организации.</summary>
|
||||||
private async Task<WeightedBarcodeSettings> GetWeightedSettingsAsync(CancellationToken ct)
|
private async Task<WeightedBarcodeSettings> GetWeightedSettingsAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,21 @@ orderby l.SortOrder
|
||||||
if (src.Lines.Count == 0)
|
if (src.Lines.Count == 0)
|
||||||
throw new PosSaleRejectedException("Пустой чек.", "lines");
|
throw new PosSaleRejectedException("Пустой чек.", "lines");
|
||||||
|
|
||||||
|
// Продать можно только с ценой продажи (владелец 08.10). Строка без цены
|
||||||
|
// (касса не знает цену — у товара её нет) → понятный отказ.
|
||||||
|
if (!src.IsReturn)
|
||||||
|
{
|
||||||
|
var zeroIds = src.Lines.Where(l => l.UnitPrice <= 0m).Select(l => l.ProductId).Distinct().ToList();
|
||||||
|
if (zeroIds.Count > 0)
|
||||||
|
{
|
||||||
|
var noPrice = await _db.Products.AsNoTracking()
|
||||||
|
.Where(p => zeroIds.Contains(p.Id) && !p.Prices.Any(pr => pr.Amount > 0m))
|
||||||
|
.Select(p => p.Name).FirstOrDefaultAsync(ct);
|
||||||
|
if (noPrice is not null)
|
||||||
|
throw new PosSaleRejectedException($"У товара «{noPrice}» нет цены продажи.", "unitPrice");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Продажа в минус РАЗРЕШЕНА (решение владельца 08.10) — pre-flight по остатку
|
// Продажа в минус РАЗРЕШЕНА (решение владельца 08.10) — pre-flight по остатку
|
||||||
// убран; минус отражается предупреждениями (NegativeStockAlert). Для возврата
|
// убран; минус отражается предупреждениями (NegativeStockAlert). Для возврата
|
||||||
// по чеку оставляем защиту от over-return (нельзя вернуть больше, чем продано).
|
// по чеку оставляем защиту от over-return (нельзя вернуть больше, чем продано).
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,20 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
|
||||||
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
|
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
|
||||||
if (input.Lines is null || input.Lines.Count == 0)
|
if (input.Lines is null || input.Lines.Count == 0)
|
||||||
return BadRequest(new { error = "Чек должен содержать хотя бы одну позицию." });
|
return BadRequest(new { error = "Чек должен содержать хотя бы одну позицию." });
|
||||||
|
// Продать можно только с ценой продажи (владелец 08.10). Строка без цены
|
||||||
|
// (UnitPrice≤0) допустима лишь если это не из-за отсутствия цены у товара —
|
||||||
|
// а если у товара вообще нет цены продажи, даём понятное сообщение.
|
||||||
|
if (!input.IsReturn)
|
||||||
|
{
|
||||||
|
var zeroPriceIds = input.Lines.Where(l => l.UnitPrice <= 0m).Select(l => l.ProductId).Distinct().ToList();
|
||||||
|
if (zeroPriceIds.Count > 0)
|
||||||
|
{
|
||||||
|
var noPrice = await _db.Products.Where(p => zeroPriceIds.Contains(p.Id) && !p.Prices.Any(pr => pr.Amount > 0m))
|
||||||
|
.Select(p => p.Name).FirstOrDefaultAsync(ct);
|
||||||
|
if (noPrice is not null)
|
||||||
|
return BadRequest(new { error = $"У товара «{noPrice}» нет цены продажи. Укажите цену в карточке или введите вручную.", code = "no-sale-price" });
|
||||||
|
}
|
||||||
|
}
|
||||||
var number = await GenerateNumberAsync(input.Date, ct);
|
var number = await GenerateNumberAsync(input.Date, ct);
|
||||||
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||||||
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
|
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
|
||||||
|
|
|
||||||
|
|
@ -93,8 +93,10 @@ public ProductInputValidator()
|
||||||
.Must(s => ValidationExt.NoControlChars(s)).WithMessage("Поле Article не должно содержать управляющих символов.");
|
.Must(s => ValidationExt.NoControlChars(s)).WithMessage("Поле Article не должно содержать управляющих символов.");
|
||||||
RuleFor(x => x.Description).Must(s => ValidationExt.NoControlChars(s))
|
RuleFor(x => x.Description).Must(s => ValidationExt.NoControlChars(s))
|
||||||
.WithMessage("Поле Description не должно содержать управляющих символов.");
|
.WithMessage("Поле Description не должно содержать управляющих символов.");
|
||||||
RuleFor(x => x.UnitOfMeasureId).NotEqual(Guid.Empty)
|
// Единица/группа НЕ обязательны в запросе — контроллер подставляет
|
||||||
.WithMessage("Не указана единица измерения.");
|
// дефолты («шт» / корневая группа). Обязательно только Наименование
|
||||||
|
// (решение владельца 08.10). Штрихкод тоже не обязателен в запросе —
|
||||||
|
// контроллер генерирует внутренний EAN-13.
|
||||||
RuleFor(x => x.Vat).InclusiveBetween(0m, 100m).When(x => x.Vat.HasValue)
|
RuleFor(x => x.Vat).InclusiveBetween(0m, 100m).When(x => x.Vat.HasValue)
|
||||||
.WithMessage("Ставка НДС должна быть от 0 до 100%.");
|
.WithMessage("Ставка НДС должна быть от 0 до 100%.");
|
||||||
RuleFor(x => x.ReferencePrice).GreaterThanOrEqualTo(0).When(x => x.ReferencePrice.HasValue);
|
RuleFor(x => x.ReferencePrice).GreaterThanOrEqualTo(0).When(x => x.ReferencePrice.HasValue);
|
||||||
|
|
|
||||||
56
src/food-market.shared/Barcodes/InternalBarcode.cs
Normal file
56
src/food-market.shared/Barcodes/InternalBarcode.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
namespace foodmarket.Shared.Barcodes;
|
||||||
|
|
||||||
|
/// <summary>Генерация внутреннего (внутримагазинного) штрихкода EAN-13 для товаров
|
||||||
|
/// без заводского кода. По решению владельца штрихкод обязателен у ЛЮБОГО товара;
|
||||||
|
/// если пользователь/импорт не задал — генерируем здесь.
|
||||||
|
///
|
||||||
|
/// Префикс выбирается так, чтобы НЕ пересекаться с весовыми/ценовыми префиксами
|
||||||
|
/// организации (иначе внутренний код распознается кассой как весовая этикетка).
|
||||||
|
/// Предпочтительно «02» (GS1 restricted circulation — внутреннее обращение, не
|
||||||
|
/// конфликтует с заводскими), иначе первый свободный 2-значный префикс.
|
||||||
|
/// Контрольная цифра — стандартная EAN-13.</summary>
|
||||||
|
public static class InternalBarcode
|
||||||
|
{
|
||||||
|
/// <summary>Контрольная цифра EAN-13 по 12 цифрам тела.</summary>
|
||||||
|
public static int Ean13CheckDigit(string body12)
|
||||||
|
{
|
||||||
|
var sum = 0;
|
||||||
|
for (int i = 0; i < 12; i++)
|
||||||
|
{
|
||||||
|
var d = body12[i] - '0';
|
||||||
|
sum += i % 2 == 0 ? d : d * 3;
|
||||||
|
}
|
||||||
|
return (10 - sum % 10) % 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Подобрать 2-значный префикс, не входящий в весовые/ценовые префиксы орг.
|
||||||
|
/// Приоритет: 02 → свободный из 20–29 → свободный из 90–99 → 02 (как крайний).</summary>
|
||||||
|
public static int PickPrefix(WeightedBarcodeSettings settings)
|
||||||
|
{
|
||||||
|
var forbidden = new HashSet<int>();
|
||||||
|
if (settings.Enabled)
|
||||||
|
{
|
||||||
|
foreach (var p in settings.WeightPrefixes) forbidden.Add(p);
|
||||||
|
foreach (var p in settings.PricePrefixes) forbidden.Add(p);
|
||||||
|
}
|
||||||
|
if (!forbidden.Contains(2)) return 2;
|
||||||
|
for (int p = 20; p <= 29; p++) if (!forbidden.Contains(p)) return p;
|
||||||
|
for (int p = 90; p <= 99; p++) if (!forbidden.Contains(p)) return p;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Сгенерировать внутренний EAN-13. <paramref name="rnd"/> — источник
|
||||||
|
/// случайности (для детерминизма в тестах можно передать свой).</summary>
|
||||||
|
public static string Generate(WeightedBarcodeSettings? settings = null, Random? rnd = null)
|
||||||
|
{
|
||||||
|
settings ??= WeightedBarcodeSettings.Default;
|
||||||
|
rnd ??= Random.Shared;
|
||||||
|
var prefix = PickPrefix(settings);
|
||||||
|
var body = new char[12];
|
||||||
|
body[0] = (char)('0' + prefix / 10);
|
||||||
|
body[1] = (char)('0' + prefix % 10);
|
||||||
|
for (int i = 2; i < 12; i++) body[i] = (char)('0' + rnd.Next(0, 10));
|
||||||
|
var body12 = new string(body);
|
||||||
|
return body12 + Ean13CheckDigit(body12);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
tests/food-market.IntegrationTests/ProductCardParityTests.cs
Normal file
84
tests/food-market.IntegrationTests/ProductCardParityTests.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using foodmarket.IntegrationTests.Support;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace foodmarket.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>Паритет карточки товара с МойСкладом + правки владельца 08.10:
|
||||||
|
/// обязательно только наименование; единица/группа по умолчанию; штрихкод
|
||||||
|
/// обязателен (автогенерация, если не задан); цена продажи не обязательна для
|
||||||
|
/// сохранения, но продать без неё нельзя.</summary>
|
||||||
|
[Collection(ApiCollection.Name)]
|
||||||
|
public class ProductCardParityTests
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory;
|
||||||
|
public ProductCardParityTests(ApiFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
private static string RandomBarcode()
|
||||||
|
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_with_only_name_succeeds_autogenerates_barcode_and_defaults()
|
||||||
|
{
|
||||||
|
var api = new ApiActor(_factory.CreateClient());
|
||||||
|
await api.SignupAndLoginAsync($"card-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
// Только наименование — ни единицы, ни группы, ни штрихкода, ни цены.
|
||||||
|
var resp = await api.Http.PostAsJsonAsync("/api/catalog/products", new { name = "Товар без ничего" });
|
||||||
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.Created, body);
|
||||||
|
var p = JsonDocument.Parse(body).RootElement;
|
||||||
|
var id = p.GetProperty("id").GetString();
|
||||||
|
|
||||||
|
// Штрихкод автогенерирован (13 цифр), единица проставлена.
|
||||||
|
var full = await api.GetJsonAsync($"/api/catalog/products/{id}");
|
||||||
|
var barcodes = full.GetProperty("barcodes").EnumerateArray().ToList();
|
||||||
|
barcodes.Should().HaveCount(1, "штрихкод обязателен → автогенерация");
|
||||||
|
barcodes[0].GetProperty("code").GetString().Should().MatchRegex("^[0-9]{13}$");
|
||||||
|
full.GetProperty("unitName").GetString().Should().NotBeNullOrEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sale_of_product_without_price_is_rejected_with_clear_message()
|
||||||
|
{
|
||||||
|
var api = new ApiActor(_factory.CreateClient());
|
||||||
|
await api.SignupAndLoginAsync($"noprice-{Guid.NewGuid():N}");
|
||||||
|
var refs = await api.LoadRefsAsync();
|
||||||
|
|
||||||
|
// Товар без цены продажи.
|
||||||
|
var create = await api.Http.PostAsJsonAsync("/api/catalog/products", new { name = "Без цены" });
|
||||||
|
create.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||||
|
var pid = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||||
|
|
||||||
|
var sale = await api.Http.PostAsJsonAsync("/api/sales/retail", new
|
||||||
|
{
|
||||||
|
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||||||
|
payment = 0, paidCash = 0m, paidCard = 0m, isReturn = false,
|
||||||
|
lines = new[] { new { productId = pid, quantity = 1m, unitPrice = 0m, discount = 0m, vatPercent = 12m, sortOrder = 0 } },
|
||||||
|
});
|
||||||
|
sale.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||||
|
(await sale.Content.ReadAsStringAsync()).Should().Contain("нет цены продажи");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QuickCreate_by_barcode_creates_minimal_product()
|
||||||
|
{
|
||||||
|
var api = new ApiActor(_factory.CreateClient());
|
||||||
|
await api.SignupAndLoginAsync($"quick-{Guid.NewGuid():N}");
|
||||||
|
var bc = RandomBarcode();
|
||||||
|
|
||||||
|
var resp = await api.Http.PostAsJsonAsync("/api/catalog/products/quick",
|
||||||
|
new { name = "Быстрый товар", barcode = bc, salePrice = 250m });
|
||||||
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.Created, body);
|
||||||
|
var id = JsonDocument.Parse(body).RootElement.GetProperty("id").GetString();
|
||||||
|
|
||||||
|
// Находится по тому самому штрихкоду.
|
||||||
|
var byBc = await api.GetJsonAsync($"/api/catalog/products/by-barcode/{bc}");
|
||||||
|
// by-barcode возвращает товар или {items:[…]}; проверим, что наш id присутствует.
|
||||||
|
byBc.GetRawText().Should().Contain(id!);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
tests/food-market.UnitTests/InternalBarcodeTests.cs
Normal file
46
tests/food-market.UnitTests/InternalBarcodeTests.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
using foodmarket.Shared.Barcodes;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace foodmarket.UnitTests;
|
||||||
|
|
||||||
|
public class InternalBarcodeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Generated_is_valid_ean13_with_correct_check_digit()
|
||||||
|
{
|
||||||
|
for (int i = 0; i < 100; i++)
|
||||||
|
{
|
||||||
|
var code = InternalBarcode.Generate();
|
||||||
|
code.Should().HaveLength(13);
|
||||||
|
code.Should().MatchRegex("^[0-9]{13}$");
|
||||||
|
var body = code[..12];
|
||||||
|
InternalBarcode.Ean13CheckDigit(body).Should().Be(code[12] - '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Default_prefix_is_02_and_not_parsed_as_weighted()
|
||||||
|
{
|
||||||
|
var code = InternalBarcode.Generate(WeightedBarcodeSettings.Default);
|
||||||
|
code.Should().StartWith("02");
|
||||||
|
// Внутренний код НЕ должен распознаваться как весовая/ценовая этикетка.
|
||||||
|
WeightedBarcode.Parse(code, WeightedBarcodeSettings.Default).IsWeighted.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Prefix_avoids_weight_and_price_prefixes()
|
||||||
|
{
|
||||||
|
// Орг. заняла и 02 под вес — генератор должен уйти на другой свободный префикс.
|
||||||
|
var settings = new WeightedBarcodeSettings
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
WeightPrefixes = new[] { 2, 20, 21, 22, 23, 24 },
|
||||||
|
PricePrefixes = new[] { 25, 26, 27, 28, 29 },
|
||||||
|
};
|
||||||
|
var prefix = InternalBarcode.PickPrefix(settings);
|
||||||
|
new[] { 2, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29 }.Should().NotContain(prefix);
|
||||||
|
var code = InternalBarcode.Generate(settings);
|
||||||
|
WeightedBarcode.Parse(code, settings).IsWeighted.Should().BeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue