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
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>
65 lines
2.9 KiB
C#
65 lines
2.9 KiB
C#
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"/> <= sale.Date < <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();
|
||
}
|