food-market/src/food-market.domain/Sales/Promotion.cs
nns 91128a7ed0
Some checks failed
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 Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Docker API / Build + push API (push) Has been cancelled
Docker API / Deploy API on stage (push) Has been cancelled
feat(loyalty+promotions): P2-12 + P2-13 — лояльность и промокоды (Sprint 9 п.1-2)
Domain:
- LoyaltyProgram { Type=Percentage|FixedAmount|PointsAccrual, Rate,
  MinSubtotal, IsActive } — org-scoped.
- LoyaltyCard { ProgramId, CounterpartyId, CardNumber unique per org,
  Balance, IsBlocked }.
- Promotion { Type=Percent|FixedDiscount, Value, Scope=All|ProductGroups|
  Products, Code unique per org, period, ProductGroupIds/ProductIds (jsonb) }.
- RetailSale: LoyaltyCardId, LoyaltyBonusApplied, LoyaltyPointsAccrued,
  PromotionId, PromotionCode (snapshot), PromotionDiscount.

EF:
- SalesConfigurations: indexes, FK Restrict, jsonb-converters для Guid-
  списков Promotion (ValueComparer для change-tracker).
- Phase9b миграция: 3 таблицы + 6 колонок на retail_sales.
- RolePermissions: LoyaltyManage, PromotionsManage добавлены (попадают
  в All() для Admin).

API:
- /api/loyalty/programs CRUD (Get/List/Create/Update/Delete; запрет delete
  при существующих картах → 409).
- /api/loyalty/cards CRUD + /issue + /{id}/block + /{id}/unblock + /lookup
  (POS использует при оплате — 404 если нет, 409 если blocked/inactive).
- /api/promotions CRUD; код уникален per org (БД-индекс + 23505 → 409).
- RetailSale.Create/Update: новые поля input.LoyaltyCardNumber +
  input.PromotionCode. Метод ApplyLoyaltyAndPromotionAsync:
  • Lookup карты, проверка active/blocked/MinSubtotal.
  • Расчёт скидки или баллов в зависимости от Type.
  • Lookup промокода, проверка периода/MinSaleAmount/scope.
  • MatchingSubtotal для Scope=ProductGroups/Products считаем по
    input.Lines (sale.Lines ещё пустой в этот момент).
  • Финальный Total = Subtotal - DiscountTotal - LoyaltyBonusApplied
    - PromotionDiscount, max(0).
- RetailSale.Post: начисление баллов на LoyaltyCard.Balance (внутри
  транзакции, чтобы rollback не оставил orphan баллы).

UI:
- /loyalty/programs — list + create/edit modal с Type/Rate/MinSubtotal.
- /loyalty/cards — list + issue modal (Program select + AsyncSelect
  counterparty + CardNumber).
- /promotions — list + create/edit modal (Type/Value/период/MinSaleAmount/Code).
- Sidebar: новый блок «Продажи» с пунктами Промокоды/Программы/Карты
  (Admin-only).
- i18n: ru.json + en.json пополнены nav-ключами.

Тесты:
- LoyaltyFlowTests (3/3 ✓): percentage уменьшает Total на 10%, points-accrual
  пополняет Balance после Post, multi-tenant lookup→404 чужой org.
- PromotionFlowTests (2/2 ✓): SALE20 уменьшает Total на 20%, невалидный
  код→400 с понятной message и field=promotionCode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 21:06:10 +05:00

65 lines
2.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using foodmarket.Domain.Common;
namespace foodmarket.Domain.Sales;
/// <summary>Тип скидки акции.</summary>
public enum PromotionType
{
/// <summary>Процент от Subtotal (или от MatchingSubtotal если ProductGroupIds/ProductIds задан).</summary>
Percent = 1,
/// <summary>Фиксированная сумма скидки в валюте чека.</summary>
FixedDiscount = 2,
}
/// <summary>Область применения акции — на весь чек или только на товары из
/// указанных групп/конкретные товары.</summary>
public enum PromotionScope
{
All = 1,
ProductGroups = 2,
Products = 3,
}
/// <summary>Промокод/акция. Применяется к розничному чеку при выбивании.
///
/// Применение: либо кассир вводит <see cref="Code"/> вручную, либо для
/// акций с пустым <see cref="Code"/> применяется автоматически если
/// чек удовлетворяет правилам (период + минимум + scope).
///
/// Период: <see cref="StartsAt"/> &lt;= sale.Date &lt; <see cref="EndsAt"/>
/// (если EndsAt = null — бессрочная). <see cref="IsActive"/> позволяет
/// быстро отключить акцию не меняя дат.</summary>
public class Promotion : TenantEntity
{
public string Name { get; set; } = null!;
public string? Description { get; set; }
/// <summary>Код для ручного ввода. null/empty = auto-apply акция (без кода).
/// Уникальный в рамках org через unique-index (`OrganizationId`, `Code`).
/// </summary>
public string? Code { get; set; }
public PromotionType Type { get; set; }
/// <summary>Значение скидки. Для Percent — [0..100], для FixedDiscount — в валюте чека.
/// Хранится как decimal(18,4).</summary>
public decimal Value { get; set; }
public PromotionScope Scope { get; set; } = PromotionScope.All;
/// <summary>Минимальная сумма чека для применения (на Subtotal). 0 = без ограничения.</summary>
public decimal MinSaleAmount { get; set; }
public DateTime StartsAt { get; set; } = DateTime.UtcNow;
public DateTime? EndsAt { get; set; }
public bool IsActive { get; set; } = true;
/// <summary>Применимые ProductGroupId'ы (для Scope=ProductGroups). Сериализуется
/// как JSONB-массив; пустой массив = ни одной группы (никогда не сматчит).</summary>
public List<Guid> ProductGroupIds { get; set; } = new();
/// <summary>Применимые ProductId'ы (для Scope=Products).</summary>
public List<Guid> ProductIds { get; set; } = new();
}