Compare commits
No commits in common. "bac527d3a8c7d8a0618acc30a9072beceac96b3d" and "bf536290927651c4ec52331b623d0944fe813835" have entirely different histories.
bac527d3a8
...
bf53629092
|
|
@ -94,13 +94,8 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput in
|
||||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
|
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var meta = await _db.ProductGroups
|
var owner = await _db.ProductGroups.Where(x => x.Id == id).Select(x => x.OrganizationId).FirstOrDefaultAsync(ct);
|
||||||
.Where(x => x.Id == id)
|
if (owner is null && !User.IsInRole("SuperAdmin")) return Forbid();
|
||||||
.Select(x => new { x.OrganizationId, x.IsSystem })
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
if (meta is null) return NotFound();
|
|
||||||
if (meta.OrganizationId is null && !User.IsInRole("SuperAdmin")) return Forbid();
|
|
||||||
if (meta.IsSystem) return BadRequest(new { error = "Системную группу удалить нельзя." });
|
|
||||||
var hasChildren = await _db.ProductGroups.AnyAsync(g => g.ParentId == id, ct);
|
var hasChildren = await _db.ProductGroups.AnyAsync(g => g.ParentId == id, ct);
|
||||||
if (hasChildren) return BadRequest(new { error = "Нельзя удалить группу с подгруппами" });
|
if (hasChildren) return BadRequest(new { error = "Нельзя удалить группу с подгруппами" });
|
||||||
var hasProducts = await _db.Products.AnyAsync(p => p.ProductGroupId == id, ct);
|
var hasProducts = await _db.Products.AnyAsync(p => p.ProductGroupId == id, ct);
|
||||||
|
|
|
||||||
|
|
@ -198,10 +198,6 @@ public async Task<ActionResult<ProductDto>> Get(Guid id, CancellationToken ct)
|
||||||
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
||||||
public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct)
|
public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (RequiredGuid.FirstMissing(
|
|
||||||
(nameof(input.UnitOfMeasureId), input.UnitOfMeasureId),
|
|
||||||
(nameof(input.ProductGroupId), input.ProductGroupId)) is { } missingFk)
|
|
||||||
return BadRequest(new { error = $"Поле {missingFk} обязательно.", field = missingFk });
|
|
||||||
if (input.Barcodes is null || input.Barcodes.Count == 0)
|
if (input.Barcodes is null || input.Barcodes.Count == 0)
|
||||||
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
|
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
|
||||||
if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing)
|
if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing)
|
||||||
|
|
@ -239,10 +235,6 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
||||||
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(
|
|
||||||
(nameof(input.UnitOfMeasureId), input.UnitOfMeasureId),
|
|
||||||
(nameof(input.ProductGroupId), input.ProductGroupId)) is { } missingFk)
|
|
||||||
return BadRequest(new { error = $"Поле {missingFk} обязательно.", field = missingFk });
|
|
||||||
if (input.Barcodes is null || input.Barcodes.Count == 0)
|
if (input.Barcodes is null || input.Barcodes.Count == 0)
|
||||||
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
|
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
|
||||||
if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing)
|
if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing)
|
||||||
|
|
|
||||||
|
|
@ -124,11 +124,6 @@ public async Task<ActionResult<SupplyDto>> Get(Guid id, CancellationToken ct)
|
||||||
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
||||||
public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct)
|
public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (RequiredGuid.FirstMissing(
|
|
||||||
(nameof(input.SupplierId), input.SupplierId),
|
|
||||||
(nameof(input.StoreId), input.StoreId),
|
|
||||||
(nameof(input.CurrencyId), input.CurrencyId)) is { } 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 = "Приёмка должна содержать хотя бы одну позицию." });
|
||||||
var number = await GenerateNumberAsync(input.Date, ct);
|
var number = await GenerateNumberAsync(input.Date, ct);
|
||||||
|
|
@ -164,43 +159,14 @@ public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input,
|
||||||
supply.Total = supply.Lines.Sum(x => x.LineTotal);
|
supply.Total = supply.Lines.Sum(x => x.LineTotal);
|
||||||
|
|
||||||
_db.Supplies.Add(supply);
|
_db.Supplies.Add(supply);
|
||||||
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
|
await _db.SaveChangesAsync(ct);
|
||||||
var dto = await GetInternal(supply.Id, ct);
|
var dto = await GetInternal(supply.Id, ct);
|
||||||
return CreatedAtAction(nameof(Get), new { id = supply.Id }, dto);
|
return CreatedAtAction(nameof(Get), new { id = supply.Id }, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>SaveChanges + перехват PostgresException 23503 (FK violation).
|
|
||||||
/// Возвращает 400 с указанием поля если FK не сошёлся (например, SupplierId
|
|
||||||
/// указывает на несуществующего контрагента) — это лучше чем 500.</summary>
|
|
||||||
private async Task<ActionResult?> SaveOrFkErrorAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _db.SaveChangesAsync(ct);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
|
|
||||||
{
|
|
||||||
// pg.ConstraintName выглядит как FK_supplies_counterparties_SupplierId
|
|
||||||
// — вытащим из него имя FK-поля для UI.
|
|
||||||
var name = pg.ConstraintName ?? "";
|
|
||||||
string field = name.Contains("Supplier") ? "supplierId"
|
|
||||||
: name.Contains("Store") ? "storeId"
|
|
||||||
: name.Contains("Currency") ? "currencyId"
|
|
||||||
: name.Contains("Product") ? "productId"
|
|
||||||
: "(unknown)";
|
|
||||||
return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (RequiredGuid.FirstMissing(
|
|
||||||
(nameof(input.SupplierId), input.SupplierId),
|
|
||||||
(nameof(input.StoreId), input.StoreId),
|
|
||||||
(nameof(input.CurrencyId), input.CurrencyId)) is { } 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 = "Приёмка должна содержать хотя бы одну позицию." });
|
||||||
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
|
@ -238,7 +204,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, C
|
||||||
}
|
}
|
||||||
supply.Total = supply.Lines.Sum(x => x.LineTotal);
|
supply.Total = supply.Lines.Sum(x => x.LineTotal);
|
||||||
|
|
||||||
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
|
await _db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -197,12 +197,6 @@ public async Task<ActionResult<RetailSaleDto>> Get(Guid id, CancellationToken ct
|
||||||
[HttpPost, Authorize(Roles = "Admin,Cashier")]
|
[HttpPost, Authorize(Roles = "Admin,Cashier")]
|
||||||
public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput input, CancellationToken ct)
|
public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (RequiredGuid.FirstMissing(
|
|
||||||
(nameof(input.StoreId), input.StoreId),
|
|
||||||
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
|
|
||||||
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
|
|
||||||
if (input.Lines is null || input.Lines.Count == 0)
|
|
||||||
return BadRequest(new { error = "Чек должен содержать хотя бы одну позицию." });
|
|
||||||
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);
|
||||||
|
|
@ -222,42 +216,14 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
|
||||||
};
|
};
|
||||||
ApplyLines(sale, input.Lines, allowFractional);
|
ApplyLines(sale, input.Lines, allowFractional);
|
||||||
_db.RetailSales.Add(sale);
|
_db.RetailSales.Add(sale);
|
||||||
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
|
await _db.SaveChangesAsync(ct);
|
||||||
var dto = await GetInternal(sale.Id, ct);
|
var dto = await GetInternal(sale.Id, ct);
|
||||||
return CreatedAtAction(nameof(Get), new { id = sale.Id }, dto);
|
return CreatedAtAction(nameof(Get), new { id = sale.Id }, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>SaveChanges + перехват PostgresException 23503 (FK violation).
|
|
||||||
/// Возвращает 400 с указанием поля если FK не сошёлся (например, StoreId
|
|
||||||
/// или RetailPointId указывают на несуществующую запись) — это лучше
|
|
||||||
/// чем 500.</summary>
|
|
||||||
private async Task<ActionResult?> SaveOrFkErrorAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _db.SaveChangesAsync(ct);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
|
|
||||||
{
|
|
||||||
var name = pg.ConstraintName ?? "";
|
|
||||||
string field = name.Contains("Store") ? "storeId"
|
|
||||||
: name.Contains("RetailPoint") ? "retailPointId"
|
|
||||||
: name.Contains("Customer") ? "customerId"
|
|
||||||
: name.Contains("Currency") ? "currencyId"
|
|
||||||
: name.Contains("Product") ? "productId"
|
|
||||||
: "(unknown)";
|
|
||||||
return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Cashier")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Cashier")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput input, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (RequiredGuid.FirstMissing(
|
|
||||||
(nameof(input.StoreId), input.StoreId),
|
|
||||||
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
|
|
||||||
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
|
|
||||||
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
if (sale is null) return NotFound();
|
if (sale is null) return NotFound();
|
||||||
if (sale.Status != RetailSaleStatus.Draft)
|
if (sale.Status != RetailSaleStatus.Draft)
|
||||||
|
|
@ -279,7 +245,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput inpu
|
||||||
sale.Lines.Clear();
|
sale.Lines.Clear();
|
||||||
ApplyLines(sale, input.Lines, allowFractional);
|
ApplyLines(sale, input.Lines, allowFractional);
|
||||||
|
|
||||||
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
|
await _db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -303,56 +269,6 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
if (sale.Status == RetailSaleStatus.Posted) return Conflict(new { error = "Чек уже проведён." });
|
if (sale.Status == RetailSaleStatus.Posted) return Conflict(new { error = "Чек уже проведён." });
|
||||||
if (sale.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести пустой чек." });
|
if (sale.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести пустой чек." });
|
||||||
|
|
||||||
// Транзакция Serializable: чтение остатков + запись stock_movements +
|
|
||||||
// апдейт stocks под одной блокировкой. Это защищает от race condition
|
|
||||||
// когда два кассира одновременно постят чеки на один и тот же товар:
|
|
||||||
// без транзакции оба бы прочли «на складе 5», списали по 3, и в итоге
|
|
||||||
// получили бы −1. Serializable заставляет вторую транзакцию подождать
|
|
||||||
// или откатиться при конфликте.
|
|
||||||
await using var tx = await _db.Database.BeginTransactionAsync(
|
|
||||||
System.Data.IsolationLevel.Serializable, ct);
|
|
||||||
|
|
||||||
// Если в одном чеке один и тот же продукт встречается несколько раз,
|
|
||||||
// нужно сравнить с остатком СУММУ всех строк, а не каждую отдельно.
|
|
||||||
var requested = sale.Lines
|
|
||||||
.GroupBy(l => l.ProductId)
|
|
||||||
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) })
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var productIds = requested.Select(r => r.ProductId).ToList();
|
|
||||||
var stocksByProduct = await _db.Stocks
|
|
||||||
.Where(s => s.StoreId == sale.StoreId && productIds.Contains(s.ProductId))
|
|
||||||
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
|
|
||||||
|
|
||||||
var insufficient = new List<object>();
|
|
||||||
foreach (var r in requested)
|
|
||||||
{
|
|
||||||
stocksByProduct.TryGetValue(r.ProductId, out var available);
|
|
||||||
if (available < r.Quantity)
|
|
||||||
{
|
|
||||||
var name = await _db.Products
|
|
||||||
.Where(p => p.Id == r.ProductId)
|
|
||||||
.Select(p => p.Name)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
insufficient.Add(new
|
|
||||||
{
|
|
||||||
productId = r.ProductId,
|
|
||||||
productName = name,
|
|
||||||
qty = r.Quantity,
|
|
||||||
available,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (insufficient.Count > 0)
|
|
||||||
{
|
|
||||||
return Conflict(new
|
|
||||||
{
|
|
||||||
error = "Недостаточно остатка для проведения чека.",
|
|
||||||
lines = insufficient,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var line in sale.Lines)
|
foreach (var line in sale.Lines)
|
||||||
{
|
{
|
||||||
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
||||||
|
|
@ -370,7 +286,6 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
sale.Status = RetailSaleStatus.Posted;
|
sale.Status = RetailSaleStatus.Posted;
|
||||||
sale.PostedAt = DateTime.UtcNow;
|
sale.PostedAt = DateTime.UtcNow;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
await tx.CommitAsync(ct);
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -174,23 +174,6 @@ public static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
|
||||||
// организации все active globals через junction org_units_of_measure.
|
// организации все active globals через junction org_units_of_measure.
|
||||||
await EnableAllActiveUnitsForOrgAsync(db, orgId, ct);
|
await EnableAllActiveUnitsForOrgAsync(db, orgId, ct);
|
||||||
|
|
||||||
// Phase5e: системная группа товаров «Все товары». Любой новый продукт,
|
|
||||||
// если юзер не указал группу явно, должен иметь хоть какую-то — без
|
|
||||||
// этой группы ProductsController.Create падал с 400 на новой орге.
|
|
||||||
var hasSystemGroup = await db.ProductGroups.IgnoreQueryFilters()
|
|
||||||
.AnyAsync(g => g.OrganizationId == orgId && g.IsSystem, ct);
|
|
||||||
if (!hasSystemGroup)
|
|
||||||
{
|
|
||||||
db.ProductGroups.Add(new ProductGroup
|
|
||||||
{
|
|
||||||
OrganizationId = orgId,
|
|
||||||
Name = "Все товары",
|
|
||||||
Path = "Все товары",
|
|
||||||
SortOrder = 0,
|
|
||||||
IsSystem = true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сидируем PriceType ТОЛЬКО если у организации не было ни одной записи.
|
// Сидируем PriceType ТОЛЬКО если у организации не было ни одной записи.
|
||||||
// Если есть — никогда не создаём «системную копию», корректность IsSystem
|
// Если есть — никогда не создаём «системную копию», корректность IsSystem
|
||||||
// обеспечивает миграция Phase3b_FixPriceTypeIsSystem (выбор по факту:
|
// обеспечивает миграция Phase3b_FixPriceTypeIsSystem (выбор по факту:
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
namespace foodmarket.Application.Common;
|
|
||||||
|
|
||||||
/// <summary>Helper для контроллеров: проверка обязательных Guid-полей в DTO.
|
|
||||||
/// Без него отсутствующее в JSON поле десериализуется в Guid.Empty
|
|
||||||
/// (000…0), и ошибка проявляется только на SaveChanges как FK violation
|
|
||||||
/// 500 — что неудобно для UI. Этот helper возвращает payload для 400.</summary>
|
|
||||||
public static class RequiredGuid
|
|
||||||
{
|
|
||||||
/// <summary>Возвращает первое пустое поле из набора (fieldName, value),
|
|
||||||
/// или null если все заполнены. Контроллер сам решает что делать с null —
|
|
||||||
/// обычно return BadRequest(new { error, field }).</summary>
|
|
||||||
public static string? FirstMissing(params (string Field, Guid Value)[] fields)
|
|
||||||
{
|
|
||||||
foreach (var (name, value) in fields)
|
|
||||||
{
|
|
||||||
if (value == Guid.Empty) return name;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -18,10 +18,4 @@ public class ProductGroup : Entity, IOptionalTenantEntity
|
||||||
/// <summary>Процент наценки на себестоимость для автоматического расчёта
|
/// <summary>Процент наценки на себестоимость для автоматического расчёта
|
||||||
/// розничной цены при проведении приёмки. NULL = автонаценка отключена.</summary>
|
/// розничной цены при проведении приёмки. NULL = автонаценка отключена.</summary>
|
||||||
public decimal? MarkupPercent { get; set; }
|
public decimal? MarkupPercent { get; set; }
|
||||||
|
|
||||||
/// <summary>Системная группа создаётся при bootstrap'е новой org как
|
|
||||||
/// дефолт, чтобы любой новый продукт мог иметь хоть какую-то группу без
|
|
||||||
/// предварительной настройки. Удалить или переименовать нельзя — иначе
|
|
||||||
/// продукты осиротеют.</summary>
|
|
||||||
public bool IsSystem { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,6 @@ private static void ConfigureProductGroup(EntityTypeBuilder<ProductGroup> b)
|
||||||
b.ToTable("product_groups");
|
b.ToTable("product_groups");
|
||||||
b.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
b.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
||||||
b.Property(x => x.Path).HasMaxLength(1000);
|
b.Property(x => x.Path).HasMaxLength(1000);
|
||||||
b.Property(x => x.IsSystem).HasDefaultValue(false);
|
|
||||||
b.HasOne(x => x.Parent)
|
b.HasOne(x => x.Parent)
|
||||||
.WithMany(x => x.Children)
|
.WithMany(x => x.Children)
|
||||||
.HasForeignKey(x => x.ParentId)
|
.HasForeignKey(x => x.ParentId)
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using foodmarket.Infrastructure.Persistence;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace foodmarket.Infrastructure.Persistence.Migrations
|
|
||||||
{
|
|
||||||
/// <summary>Phase5e — добавляем product_groups.IsSystem (default false).
|
|
||||||
/// Используется для бутстрап-группы «Все товары», создаваемой при
|
|
||||||
/// запуске новой org: продукт без явной группы попадает в неё, удалить
|
|
||||||
/// её нельзя, иначе продукты осиротеют.</summary>
|
|
||||||
[DbContext(typeof(AppDbContext))]
|
|
||||||
[Migration("20260508200000_Phase5e_ProductGroupIsSystem")]
|
|
||||||
public partial class Phase5e_ProductGroupIsSystem : Migration
|
|
||||||
{
|
|
||||||
protected override void Up(MigrationBuilder b)
|
|
||||||
{
|
|
||||||
b.AddColumn<bool>(
|
|
||||||
name: "IsSystem",
|
|
||||||
schema: "public",
|
|
||||||
table: "product_groups",
|
|
||||||
type: "boolean",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Down(MigrationBuilder b)
|
|
||||||
{
|
|
||||||
b.DropColumn(
|
|
||||||
name: "IsSystem",
|
|
||||||
schema: "public",
|
|
||||||
table: "product_groups");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { api } from '@/lib/api'
|
||||||
import { Modal } from '@/components/Modal'
|
import { Modal } from '@/components/Modal'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { Field, TextInput, Select, AsyncSelect } from '@/components/Field'
|
import { Field, TextInput, Select, AsyncSelect } from '@/components/Field'
|
||||||
import { useUnits, useProductGroups } from '@/lib/useLookups'
|
import { useUnits } from '@/lib/useLookups'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { BarcodeType, type Product } from '@/lib/types'
|
import { BarcodeType, type Product } from '@/lib/types'
|
||||||
import { generateEan13InternalPrefix2 } from '@/lib/barcode'
|
import { generateEan13InternalPrefix2 } from '@/lib/barcode'
|
||||||
|
|
@ -26,7 +26,6 @@ interface Props {
|
||||||
* pre-fill'ятся из набранного запроса в зависимости от типа. */
|
* pre-fill'ятся из набранного запроса в зависимости от типа. */
|
||||||
export function ProductQuickCreateModal({ open, onClose, prefill, onCreated }: Props) {
|
export function ProductQuickCreateModal({ open, onClose, prefill, onCreated }: Props) {
|
||||||
const units = useUnits()
|
const units = useUnits()
|
||||||
const groups = useProductGroups()
|
|
||||||
const org = useOrgSettings()
|
const org = useOrgSettings()
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
|
|
@ -67,13 +66,7 @@ export function ProductQuickCreateModal({ open, onClose, prefill, onCreated }: P
|
||||||
const sht = units.data.find((u) => u.code === '796') ?? units.data[0]
|
const sht = units.data.find((u) => u.code === '796') ?? units.data[0]
|
||||||
setUnitId(sht.id)
|
setUnitId(sht.id)
|
||||||
}
|
}
|
||||||
// Дефолт ProductGroup — системная «Все товары» (Phase5e), либо
|
}, [open, units.data, unitId])
|
||||||
// единственная имеющаяся.
|
|
||||||
if (!groupId && groups.data?.length) {
|
|
||||||
const sys = groups.data.find((g) => g.name === 'Все товары') ?? (groups.data.length === 1 ? groups.data[0] : undefined)
|
|
||||||
if (sys) setGroupId(sys.id)
|
|
||||||
}
|
|
||||||
}, [open, units.data, groups.data, unitId, groupId])
|
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { api } from '@/lib/api'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { Field, TextInput, TextArea, Select, AsyncSelect, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
|
import { Field, TextInput, TextArea, Select, AsyncSelect, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
|
||||||
import {
|
import {
|
||||||
useUnits, useCountries, useCurrencies, usePriceTypes, useProductGroups,
|
useUnits, useCountries, useCurrencies, usePriceTypes,
|
||||||
} from '@/lib/useLookups'
|
} from '@/lib/useLookups'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { BarcodeType, Packaging, type Product } from '@/lib/types'
|
import { BarcodeType, Packaging, type Product } from '@/lib/types'
|
||||||
|
|
@ -57,7 +57,6 @@ export function ProductEditPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const units = useUnits()
|
const units = useUnits()
|
||||||
const groups = useProductGroups()
|
|
||||||
const countries = useCountries()
|
const countries = useCountries()
|
||||||
const currencies = useCurrencies()
|
const currencies = useCurrencies()
|
||||||
const priceTypes = usePriceTypes()
|
const priceTypes = usePriceTypes()
|
||||||
|
|
@ -100,14 +99,6 @@ export function ProductEditPage() {
|
||||||
setForm((f) => ({ ...f, unitOfMeasureId: sht.id }))
|
setForm((f) => ({ ...f, unitOfMeasureId: sht.id }))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Дефолт ProductGroup — системная «Все товары» (Phase5e). Если у орги
|
|
||||||
// ровно одна группа (тот самый системный bootstrap) — авто-выбираем её,
|
|
||||||
// чтобы пользователь мог сохранить продукт без дополнительного клика.
|
|
||||||
if (isNew && form.productGroupId === '' && groups.data?.length) {
|
|
||||||
const sys = groups.data.find((g) => g.name === 'Все товары') ?? (groups.data.length === 1 ? groups.data[0] : undefined)
|
|
||||||
if (sys) setForm((f) => ({ ...f, productGroupId: sys.id }))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNew && form.purchaseCurrencyId === '' && currencies.data?.length) {
|
if (isNew && form.purchaseCurrencyId === '' && currencies.data?.length) {
|
||||||
const def = org.data?.defaultCurrencyId
|
const def = org.data?.defaultCurrencyId
|
||||||
? currencies.data?.find(c => c.id === org.data!.defaultCurrencyId)
|
? currencies.data?.find(c => c.id === org.data!.defaultCurrencyId)
|
||||||
|
|
@ -133,7 +124,7 @@ export function ProductEditPage() {
|
||||||
barcodes: [{ code: generateEan13InternalPrefix2(), type: BarcodeType.Ean13, isPrimary: true }],
|
barcodes: [{ code: generateEan13InternalPrefix2(), type: BarcodeType.Ean13, isPrimary: true }],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}, [isNew, units.data, groups.data, currencies.data, org.data, form.unitOfMeasureId, form.productGroupId, form.purchaseCurrencyId, form.vat, form.article, form.barcodes.length])
|
}, [isNew, units.data, currencies.data, org.data, form.unitOfMeasureId, form.purchaseCurrencyId, form.vat, form.article, form.barcodes.length])
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
|
|
||||||
|
|
@ -196,10 +196,7 @@ export function RetailSaleEditPage() {
|
||||||
const removeLine = (i: number) =>
|
const removeLine = (i: number) =>
|
||||||
setForm({ ...form, lines: form.lines.filter((_, ix) => ix !== i) })
|
setForm({ ...form, lines: form.lines.filter((_, ix) => ix !== i) })
|
||||||
|
|
||||||
// Сохранять Draft без позиций — бессмысленно (его потом всё равно
|
const canSave = !!form.storeId && !!form.currencyId && isDraft
|
||||||
// нельзя провести). Сервер тоже блокирует это с 400, но UX лучше когда
|
|
||||||
// кнопка disabled с подсказкой.
|
|
||||||
const canSave = !!form.storeId && !!form.currencyId && isDraft && form.lines.length > 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={onSubmit} className="flex flex-col h-full">
|
<form onSubmit={onSubmit} className="flex flex-col h-full">
|
||||||
|
|
@ -231,12 +228,7 @@ export function RetailSaleEditPage() {
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isDraft && (
|
{isDraft && (
|
||||||
<Button
|
<Button type="submit" variant="secondary" disabled={!canSave || save.isPending}>
|
||||||
type="submit"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={!canSave || save.isPending}
|
|
||||||
title={form.lines.length === 0 ? 'Добавьте хотя бы одну позицию' : undefined}
|
|
||||||
>
|
|
||||||
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
|
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue