feat(money): бэкенд модуля «Деньги» для простого режима (пп.0–6)
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

Сверка с МойСкладом (docs/money-moysklad-reference.md) + домен/инфра/API:

- Счета и кассы (MoneyAccount: Cash/Safe/Bank/Acquiring, начальный остаток,
  привязка к магазину, IsDefault/IsActive, инвариант CurrentBalance).
- Денежные документы (MoneyTransaction: PaymentIn/Out, CashIn/Out, переводы
  парой TransferOut↔TransferIn; статьи расходов ExpenseItem; проведение/отмена
  в Serializable-транзакции через MoneyService).
- Оплата поставщикам: Supply/SupplierReturn.PayedSum + MoneyTransactionLink,
  SuppliesController отдаёт PaymentStatus + фильтр paymentStatus.
- Взаиморасчёты: CounterpartyAdjustment + MoneyReportsController.SupplierDebts.
- Смены кассы: CashShift + CashDrawerOperation (open/close, expected↔actual,
  Z-отчёт), запрет розничной продажи без открытой смены в RetailSalesController.
- Отчёт «Деньги»: balances/movement с проверкой инварианта остатков.

Права: RolePermissions +MoneyView/MoneyEdit/CashShiftOperate; сидер раздаёт
кассиру CashShiftOperate+MoneyView, кладовщику — нет; 5 системных статей
расходов на новую орг. Миграция Phase33a (ручная, с [Migration]+[DbContext]).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-07 21:52:04 +05:00
parent e45600df4b
commit a25b5b3277
27 changed files with 3048 additions and 10 deletions

View file

@ -0,0 +1,49 @@
# Модуль «Деньги» (простой режим) — прогресс
**Старт:** 2026-10-07.
**Решение владельца от 07.10.2026:** собрать базовый финансовый контур
в простом режиме (счета, кассовые/безналичные движения, оплата приёмок,
долги поставщикам, смены кассы, отчёт «Деньги»).
## Чек-лист
- [x] **0. Сверка с МойСкладом** → `docs/money-moysklad-reference.md`
- [x] **1. Счета и кассы** (тип: касса / сейф / банк, начальный остаток,
магазин, активный флаг; поддержка нескольких банковских счетов на магазин;
счёт эквайринга) — domain + миграция, сидер статей расходов
- [x] **2. Денежные документы** (PaymentIn/Out, CashIn/Out, Transfer между
своими счетами; статьи расходов; проведение/отмена; аудит) — MoneyService
с Serializable-транзакциями, controllers `MoneyTransactionsController` +
`ExpenseItemsController`
- [x] **3. Оплата поставщикам** (платёж ↔ приёмки/возвраты с распределением,
payedSum на приёмке, статус оплаты, кнопка «Оплатить» из приёмки,
частичная оплата) — колонка `PayedSum`, `MoneyTransactionLink`,
`SuppliesController` отдаёт `PaymentStatus` + фильтр
- [x] **4. Взаиморасчёты (долги поставщикам)** (отчёт, корректировки,
самая старая неоплаченная дата) — `CounterpartyAdjustmentsController`,
`MoneyReportsController.SupplierDebts`
- [x] **5. Смены кассы** (open/close, внесения/выплаты, «должно быть ↔ по факту»,
Z-отчёт, запрет продажи без смены) — `CashShiftsController`, Z-отчёт
в `/shifts/{id}/report`, проверка в `RetailSalesController.PostCoreAsync`
- [x] **6. Отчёт «Деньги»** (остатки по счетам, движение за период,
инвариант остатков) — `MoneyReportsController.Balances`/`Movement`
- [ ] **7. UI и меню** (раздел «Деньги», блок оплаты в приёмке,
mobile-first, права кассир/кладовщик/админ)
- [ ] **8. Тесты** (unit + integration на реальном Postgres + e2e на стенде
изнутри сети + меню-прогон watchdog step 9 зелёный)
- [ ] **9. Деплой + документация** (`~/deploy-stage.sh`, `docs/money.md`)
## Границы
**НЕ в этом спринте:**
банк-клиент / выгрузка платёжек, импорт выписок, валюты, зарплата,
полный P&L.
**Запрещено трогать:** прод, shared-postgres, `global.json`, gateway nginx,
соседний проект `sklad`.
## Журнал
### 2026-10-07
- Старт. Создан этот прогресс-док. Готовлю `docs/money-moysklad-reference.md`
через headless Playwright (SPA-рендер нужен).

View file

@ -0,0 +1,257 @@
# МойСклад — опорные факты для модуля «Деньги»
Собрано 2026-10-07 через headless Playwright со страниц
`https://dev.moysklad.ru/doc/api/remap/1.2/#/…`. Код скрейпера:
`tests/e2e/scrape-moysklad.ts`, сырые дампы: `/tmp/moysklad-dump/*.txt`.
Задача этого документа — зафиксировать **именно те поля и связи
МойСклада**, которые мы должны уметь воспроизводить. Своими названиями
колонок/эндпоинтов пользуемся по смыслу их, чтобы потом синхронизация
легла без хирургии.
---
## 1. Юрлицо → счета (`organization.accounts`)
Юрлицо (`organization`) содержит коллекцию банковских счетов (`accounts`).
Один помечен «основным» (`isDefault`). Поля счёта:
| Поле | Тип | Назначение |
|---|---|---|
| `id` | UUID | — |
| `accountNumber` | String(255), **required** | номер счёта |
| `bankName` | String(255) | наименование банка |
| `bankLocation` | String(255) | адрес банка |
| `bic` | String(255) | БИК/BIC |
| `correspondentAccount` | String(255) | корр. счёт |
| `currency` | Meta, expand | валюта счёта |
| `isDefault` | Boolean, **required** | основной ли |
**Следствие для нас:** на одну организацию у магазина может быть
несколько банковских счетов (Kaspi, Halyk и т.п.) + «касса» как отдельная
сущность. МойСклад касса/сейф — это уже свойство ордеров, а не поле
юрлица. У нас решено объединить это в единый справочник «Счета и кассы»
с типом `Bank | Cash | Safe | Acquiring` — так удобнее для простого
магазина, при синхронизации кассы маппим в документы cashin/cashout
«без закрывающего `organizationAccount`», а банк — в payment с
`organizationAccount` = нашим `MoneyAccountId`.
---
## 2. Входящий платёж (`paymentin`) и исходящий (`paymentout`) — **безнал**
Основные поля (общие для in/out):
| Поле | Тип | Назначение |
|---|---|---|
| `agent` | Meta, **required** | контрагент |
| `agentAccount` | Meta | счёт контрагента |
| `organization` | Meta, **required** | наше юрлицо |
| `organizationAccount` | Meta | **наш** банковский счёт (списание / зачисление) |
| `moment` | DateTime, **required** | дата документа |
| `sum` | Float, **required** | сумма (в копейках у МойСклада; у нас — decimal в тенге) |
| `rate` | Object | валюта + курс |
| `paymentPurpose` | String(255), **required** | назначение платежа |
| `incomingDate` / `incomingNumber` | — | исходящий номер/дата платёжки (для входящих) |
| `applicable` | Boolean, **required** | «проведено» |
| `expenseItem` | Meta (**только у paymentout и cashout**) | статья расходов |
| `operations` | Array(Meta + `linkedSum`) | **привязка к документам** |
| `vatSum` | Float | НДС |
### Привязка `operations`
Это ключевая вещь: один платёж/ордер привязывается к N документам,
на каждый — своя `linkedSum`.
**paymentin / cashin — что может оплачивать:** `customerorder`,
`purchasereturn`, `demand`, `invoiceout`, `commissionreportin`, `retailShift`.
→ оплата **от покупателя** (включая возврат поставщику — деньги возвращаются
поставщиком обратно к нам).
**paymentout / cashout — что может оплачивать:** `supply` (приёмка),
`purchaseorder`, `invoicein`, `commissionreportout`, `customerreturn`
(возврат покупателю).
→ оплата **поставщикам и клиентам (возвраты)**.
### `noClosingDocs`
У `paymentout`/`cashout` есть флаг `noClosingDocs`. При `true` — платёж
«без закрывающих документов», `operations` должен быть пуст. При `false` —
`operations` обязателен для разноски. Нельзя передать `true` и
непустой `operations` одновременно.
### Валидация валюты платежа
Если задан `organizationAccount` в валюте, отличной от валюты учёта
компании, валюта документа **должна совпадать с валютой счёта** (иначе
ошибка 22004). У приёмки `cashin`/`cashout` этой валидации нет.
---
## 3. Приходный / расходный ордер (`cashin`, `cashout`) — **наличные**
Поля в целом совпадают с paymentin/paymentout, но:
- **нет `organizationAccount`** — наличные деньги у МойСклада не
привязаны к конкретной «кассе», а трекаются суммарно по организации.
У нас это неудобно (магазин хочет видеть «касса магазина Абая», «касса
магазина Жибек Жолы», «сейф директора») — поэтому вводим внутреннее
поле `MoneyAccountId` обязательным для cash-документов тоже.
- `paymentPurpose` называется «Основание».
- У `cashout` есть `expenseItem` и `noClosingDocs`.
---
## 4. Внесение/выплата кассы смены (`retaildrawercashin`, `retaildrawercashout`)
Отдельная сущность — именно операции кассира внутри открытой смены
(«разменять 50к из сейфа», «отдать 10к курьеру из кассы»).
Ключевые поля:
| Поле | Тип | Назначение |
|---|---|---|
| `agent` | Meta, **required** | **сотрудник** (не контрагент!) — кассир |
| `organization` | Meta, **required** | юрлицо |
| `sum` | Float, **required**, read-only | сумма |
| `moment` | DateTime | момент |
| `retailShift` | Meta, **required** | смена, в рамках которой операция |
| `description` | String(4096) | комментарий (у нас — «Причина») |
| `applicable` | Boolean | проведено |
У МойСклада эти документы «только для чтения `sum`» — считаются от
исходной операции. У нас будет проще: кассир вручную вводит сумму +
причину, валидация >0.
---
## 5. Розничная смена (`retailshift`)
| Поле | Тип | Назначение |
|---|---|---|
| `retailStore` | Meta, **required** | точка продаж (у нас RetailPoint) |
| `store` | Meta | склад (авто из retailStore если не задан) |
| `organization` | Meta, **required** | юрлицо |
| `organizationAccount` | Meta | счёт для безнал. выручки (эквайринг) |
| `acquire` | Meta | **банк-эквайер** по картам |
| `qrAcquire` | Meta | банк-эквайер по QR-коду |
| `bankPercent` | Double | % комиссии эквайера по картам |
| `bankComission` | Double | **сумма** комиссии (≤ выручки по картам) |
| `qrBankPercent`, `qrBankComission` | — | то же для QR |
| `proceedsCash` | Float, read-only | **выручка наличными** |
| `proceedsNoCash` | Float, read-only | **выручка безнал** |
| `receivedCash` | Float, read-only | получено наличными (с учётом внесений/выплат) |
| `receivedNoCash` | Float, read-only | получено безнал |
| `closeDate` | DateTime | момент закрытия смены |
| `operations` | Array | связанные операции (продажи, возвраты) |
| `paymentOperations` | Array, read-only | связанные платежи |
| `cheque.start / cheque.end` | Object | ККТ-признаки открытия/закрытия: `fnNumber`, `kktRegNumber`, `fiscalDocNumber`, `fiscalDocSign`, `shiftNumber`, `time`, (end) `chequesTotal`, `fiscalDocsTotal`. |
**Схема расчёта наличных в смене (повторяем в своём коде):**
```
expectedCash = openingFloat // «разменник» на старте
+ proceedsCash // продажи наличными
- cashRefunds // возвраты наличными
+ Σ retaildrawercashin // внесения
- Σ retaildrawercashout // выплаты
```
«По факту» вводит кассир при закрытии; разница =
`actualCash - expectedCash`, подсвечиваем в UI.
Выручка по картам отражается на счёте `organizationAccount` смены
(= наш «счёт эквайринга»). Комиссия банка — отдельный расход
(ежемесячно/ежедневно, зависит от договора).
---
## 6. Корректировка взаиморасчётов (`counterpartyadjustment`)
Простой документ: `agent` (контрагент), `organization`, `moment`,
`sum`, `applicable`, `description`. Используется для:
1. **стартовых остатков по долгам** (начали работать с поставщиком,
должны 500к — заводим корректировку);
2. ручного исправления разошедшихся расчётов.
У нас: применяем для простого случая «начальный долг поставщику»; знак
определяется интерфейсом («мы должны» / «нам должны»).
---
## 7. Отчёт «Деньги» (`/report/money/plotseries`)
Разделяется на две части:
- **plotseries** — график движения за период: `credit` (приход),
`debit` (расход), и массив `series` с точками `date / credit / debit / balance`.
Параметры: `momentFrom`, `momentTo`, `interval` ∈ {hour, day, month}.
- (вторая часть — `/report/money` — остатки по счетам, в нашем дампе
обрезано, но из UI МойСклада знаем: массив объектов `{account, sum}`,
суммарный баланс.)
Фильтры: `organization`, `project`.
**Для нас:** реализуем два эндпоинта в одном контроллере —
`GET /api/money/report/balances` и `GET /api/money/report/movement?from=&to=&interval=`,
второй отдаёт credit/debit/balance в формате plotseries.
---
## 8. Отчёт «Показатели контрагентов» (`/report/counterparty`) — долг
В одном ряду по контрагенту:
| Поле | Назначение |
|---|---|
| `balance` | **баланс взаиморасчётов**: >0 — контрагент должен нам, <0 — мы должны ему (знак зависит от типа) |
| `demandsCount`, `demandsSum` | продажи |
| `returnsCount`, `returnsSum` | возвраты |
| `averageReceipt` | средний чек |
| `profit` | прибыль |
| `firstDemandDate`, `lastDemandDate` | — |
Для простого режима нам нужен **только `balance`** и аналог
`oldestUnpaidDate` (у МойСклада нет этого поля в отчёте, но мы считаем
сами из `supplyPayedSum < supplyTotal`).
Формула долга поставщику у нас:
```
debtToSupplier(supplierId)
= Σ supplies.total (status = Posted, supplierId = X)
− Σ supplierReturns.total (аналогично)
− Σ supplierPayments (paymentout + cashout по этому поставщику)
+ Σ counterpartyAdjustments.sumSigned (стартовые долги)
```
---
## 9. `payedSum` на документах закупки/продажи
Эмпирически у МойСклада на `supply`, `demand`, `purchasereturn`,
`customerreturn`, `invoiceout`, `invoicein` есть вычисляемое поле
`payedSum` — «сколько из суммы документа уже оплачено привязанными
платежами/ордерами». Оно не передаётся в POST, апдейтится сервером
на каждое изменение `operations/linkedSum` платежа.
**У нас:** добавляем `PayedSum` на `Supply` и `SupplierReturn`,
пересчитываем в `MoneyService` при проведении/отмене платежа/ордера,
статус оплаты вычисляется в SQL/контроллере:
`unpaid | partial | paid`.
---
## 10. Что НЕ берём в простой режим
- `project`, `salesChannel`, `contract`, `state`, `group`, `attributes`,
`files`, `syncId`, `externalCode` — всё для расширенного/интеграции.
- `vatSum` — у магазина простого режима НДС считается общий, а не на
уровне платежа; поле опустим в UI, в БД оставим `nullable` на будущее.
- `invoiceout`/`invoicein` (счета) — у нас нет сущности «Счёт», только
документы.
- `incomingDate`/`incomingNumber` — не нужно, у магазина нет
документооборота с банком.
Эти поля не создаём в простом режиме, но резервируем места в именованиях
колонок на будущее.

View file

@ -0,0 +1,206 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Application.Common;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Application.Money;
using foodmarket.Domain.Money;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Money;
/// <summary>Смены кассы — открыть/закрыть + внесения/выплаты. Z-отчёт
/// получаем через <c>GET /{id}/report</c>, который возвращает всё в
/// печатаемом виде (суммы продаж/возвратов, нал/карта, разница).
///
/// Если текущий пользователь — не Admin, он видит только свои смены;
/// если Admin — все. Это закреплено в контроллере, а не в query-filter'е
/// (нужна отдельная логика «моя смена» для кассира).</summary>
[ApiController]
[Authorize]
[Route("api/money/shifts")]
public class CashShiftsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly IMoneyService _money;
public CashShiftsController(AppDbContext db, ITenantContext tenant, IMoneyService money)
{
_db = db;
_tenant = tenant;
_money = money;
}
public record CashShiftRow(
Guid Id, string Number, CashShiftStatus Status,
Guid RetailPointId, string RetailPointName,
Guid CashAccountId, string CashAccountName,
Guid? AcquiringAccountId, string? AcquiringAccountName,
Guid CashierUserId, string? CashierName,
DateTime OpenedAt, DateTime? ClosedAt,
decimal OpeningFloat,
decimal SalesCash, decimal SalesCard,
decimal ReturnsCash, decimal ReturnsCard,
decimal DrawerIn, decimal DrawerOut,
decimal ExpectedCash, decimal? ActualCash, decimal CashDifference,
string? Notes, uint Xmin);
public record CashDrawerOpRow(Guid Id, CashDrawerDirection Direction, decimal Amount, string Reason, DateTime At, Guid ByUserId);
public record OpenShiftInput(
Guid RetailPointId, Guid CashAccountId,
Guid? AcquiringAccountId,
[Range(0, 1e12)] decimal OpeningFloat);
public record CloseShiftInput(
[Range(0, 1e12)] decimal ActualCash,
[MaxLength(1000)] string? Notes);
public record DrawerOpInput(
CashDrawerDirection Direction,
[Range(0.0001, 1e12)] decimal Amount,
[Required, MaxLength(500)] string Reason);
[HttpGet, RequiresPermission("MoneyView")]
public async Task<ActionResult<PagedResult<CashShiftRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] CashShiftStatus? status,
[FromQuery] Guid? retailPointId,
[FromQuery] bool? mine,
CancellationToken ct)
{
var q = _db.CashShifts.AsNoTracking().AsQueryable();
if (status is not null) q = q.Where(x => x.Status == status);
if (retailPointId is not null) q = q.Where(x => x.RetailPointId == retailPointId);
if (mine == true)
{
var userId = CurrentUserId();
q = q.Where(x => x.CashierUserId == userId);
}
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.OpenedAt)
.Skip(req.Skip).Take(req.Take)
.Select(x => new
{
x.Id, x.Number, x.Status, x.RetailPointId,
RetailPointName = _db.RetailPoints.Where(r => r.Id == x.RetailPointId).Select(r => r.Name).FirstOrDefault() ?? "",
x.CashAccountId,
CashAccountName = _db.MoneyAccounts.Where(a => a.Id == x.CashAccountId).Select(a => a.Name).FirstOrDefault() ?? "",
x.AcquiringAccountId,
AcquiringAccountName = x.AcquiringAccountId == null ? null : _db.MoneyAccounts.Where(a => a.Id == x.AcquiringAccountId).Select(a => a.Name).FirstOrDefault(),
x.CashierUserId,
x.OpenedAt, x.ClosedAt,
x.OpeningFloat,
x.SalesCash, x.SalesCard, x.ReturnsCash, x.ReturnsCard,
x.DrawerIn, x.DrawerOut, x.ExpectedCash, x.ActualCash, x.CashDifference,
x.Notes,
Xmin = Microsoft.EntityFrameworkCore.EF.Property<uint>(x, "xmin"),
})
.ToListAsync(ct);
var items = rows.Select(r => new CashShiftRow(
r.Id, r.Number, r.Status, r.RetailPointId, r.RetailPointName,
r.CashAccountId, r.CashAccountName,
r.AcquiringAccountId, r.AcquiringAccountName,
r.CashierUserId, null,
r.OpenedAt, r.ClosedAt, r.OpeningFloat,
r.SalesCash, r.SalesCard, r.ReturnsCash, r.ReturnsCard,
r.DrawerIn, r.DrawerOut, r.ExpectedCash, r.ActualCash, r.CashDifference,
r.Notes, r.Xmin)).ToList();
return new PagedResult<CashShiftRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}"), RequiresPermission("MoneyView")]
public async Task<ActionResult<object>> Get(Guid id, CancellationToken ct)
{
var s = await _db.CashShifts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
if (s is null) return NotFound();
var ops = await _db.CashDrawerOperations.AsNoTracking()
.Where(o => o.CashShiftId == id)
.OrderBy(o => o.At)
.Select(o => new CashDrawerOpRow(o.Id, o.Direction, o.Amount, o.Reason, o.At, o.ByUserId))
.ToListAsync(ct);
return Ok(new { shift = s, drawerOperations = ops });
}
[HttpGet("current"), RequiresPermission("CashShiftOperate")]
public async Task<ActionResult<object>> Current([FromQuery] Guid retailPointId, CancellationToken ct)
{
var userId = CurrentUserId();
var s = await _db.CashShifts.AsNoTracking()
.FirstOrDefaultAsync(x => x.RetailPointId == retailPointId && x.Status == CashShiftStatus.Open, ct);
return s is null ? Ok(new { open = false }) : Ok(new { open = true, shift = s, isMine = s.CashierUserId == userId });
}
[HttpPost("open"), RequiresPermission("CashShiftOperate")]
public async Task<ActionResult<object>> Open([FromBody] OpenShiftInput input, CancellationToken ct)
{
var userId = CurrentUserId();
var r = await _money.OpenShiftAsync(input.RetailPointId, input.CashAccountId,
input.AcquiringAccountId, input.OpeningFloat, userId, ct);
if (r.Status != MoneyServiceStatus.Ok)
return Problem(r.Message, statusCode: r.Status == MoneyServiceStatus.ShiftAlreadyOpen ? 409 : 400,
type: $"https://food-market.kz/errors/{r.ErrorCode}");
return Ok(new { id = r.ShiftId, number = r.Number });
}
[HttpPost("{id:guid}/close"), RequiresPermission("CashShiftOperate")]
public async Task<ActionResult<object>> Close(Guid id, [FromBody] CloseShiftInput input, CancellationToken ct)
{
var userId = CurrentUserId();
var r = await _money.CloseShiftAsync(id, input.ActualCash, userId, input.Notes, ct);
if (r.Status != MoneyServiceStatus.Ok)
return Problem(r.Message, statusCode: 400,
type: $"https://food-market.kz/errors/{r.ErrorCode}");
return Ok(new { expectedCash = r.ExpectedCash, difference = r.CashDifference });
}
[HttpPost("{id:guid}/drawer"), RequiresPermission("CashShiftOperate")]
public async Task<ActionResult<object>> Drawer(Guid id, [FromBody] DrawerOpInput input, CancellationToken ct)
{
var userId = CurrentUserId();
var r = await _money.AddDrawerOperationAsync(id, input.Direction, input.Amount, input.Reason, userId, ct);
if (r.Status != MoneyServiceStatus.Ok)
return Problem(r.Message, statusCode: r.Status == MoneyServiceStatus.NotFound ? 404 : 400,
type: $"https://food-market.kz/errors/{r.ErrorCode}");
return Ok(new { id = r.OperationId });
}
/// <summary>Z-отчёт: все цифры смены + внесения/выплаты для печати.</summary>
[HttpGet("{id:guid}/report"), RequiresPermission("MoneyView")]
public async Task<ActionResult<object>> Report(Guid id, CancellationToken ct)
{
var s = await _db.CashShifts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
if (s is null) return NotFound();
var ops = await _db.CashDrawerOperations.AsNoTracking()
.Where(o => o.CashShiftId == id)
.OrderBy(o => o.At)
.ToListAsync(ct);
return Ok(new
{
number = s.Number,
openedAt = s.OpenedAt,
closedAt = s.ClosedAt,
openingFloat = s.OpeningFloat,
sales = new { cash = s.SalesCash, card = s.SalesCard },
returns = new { cash = s.ReturnsCash, card = s.ReturnsCard },
drawer = new { @in = s.DrawerIn, @out = s.DrawerOut, items = ops },
expectedCash = s.ExpectedCash,
actualCash = s.ActualCash,
cashDifference = s.CashDifference,
notes = s.Notes,
});
}
private Guid CurrentUserId()
{
var sub = User.FindFirst("sub")?.Value
?? User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return Guid.TryParse(sub, out var g) ? g : Guid.Empty;
}
}

View file

@ -0,0 +1,169 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Application.Common;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Money;
using foodmarket.Infrastructure.Money;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Money;
/// <summary>Корректировки взаиморасчётов — стартовый долг или ручное исправление.
/// Проведение влияет на отчёт долгов (SupplierDebtsController), на баланс счетов — нет.</summary>
[ApiController]
[Authorize]
[Route("api/money/adjustments")]
public class CounterpartyAdjustmentsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public CounterpartyAdjustmentsController(AppDbContext db, ITenantContext tenant)
{
_db = db;
_tenant = tenant;
}
public record AdjustmentRow(Guid Id, string Number, DateTime Date,
Guid CounterpartyId, string CounterpartyName,
CounterpartyAdjustmentDirection Direction,
decimal Amount, string? Notes,
MoneyTransactionStatus Status, DateTime? PostedAt, uint Xmin);
public record AdjustmentInput(
DateTime Date,
Guid CounterpartyId,
CounterpartyAdjustmentDirection Direction,
[Range(0.0001, 1e12)] decimal Amount,
[MaxLength(1000)] string? Notes,
uint? Xmin = null);
[HttpGet, RequiresPermission("MoneyView")]
public async Task<ActionResult<PagedResult<AdjustmentRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] Guid? counterpartyId,
[FromQuery] MoneyTransactionStatus? status,
CancellationToken ct)
{
var q = _db.CounterpartyAdjustments.AsNoTracking().AsQueryable();
if (counterpartyId is not null) q = q.Where(x => x.CounterpartyId == counterpartyId);
if (status is not null) q = q.Where(x => x.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.Date)
.Skip(req.Skip).Take(req.Take)
.Select(x => new AdjustmentRow(
x.Id, x.Number, x.Date, x.CounterpartyId,
_db.Counterparties.Where(c => c.Id == x.CounterpartyId).Select(c => c.Name).FirstOrDefault() ?? "",
x.Direction, x.Amount, x.Notes, x.Status, x.PostedAt,
Microsoft.EntityFrameworkCore.EF.Property<uint>(x, "xmin")))
.ToListAsync(ct);
return new PagedResult<AdjustmentRow> { Items = rows, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpPost, RequiresPermission("MoneyEdit")]
public async Task<ActionResult<AdjustmentRow>> Create([FromBody] AdjustmentInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? Guid.Empty;
var a = new CounterpartyAdjustment
{
OrganizationId = orgId,
Number = await MoneyNumbering.NextAdjustmentNumberAsync(_db, orgId, input.Date.Year, ct),
Date = input.Date,
CounterpartyId = input.CounterpartyId,
Direction = input.Direction,
Amount = input.Amount,
Notes = input.Notes,
Status = MoneyTransactionStatus.Draft,
};
_db.CounterpartyAdjustments.Add(a);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = a.Id }, await ReadOneAsync(a.Id, ct));
}
[HttpGet("{id:guid}"), RequiresPermission("MoneyView")]
public async Task<ActionResult<AdjustmentRow>> Get(Guid id, CancellationToken ct)
{
var r = await ReadOneAsync(id, ct);
return r is null ? NotFound() : r;
}
[HttpPut("{id:guid}"), RequiresPermission("MoneyEdit")]
public async Task<ActionResult<AdjustmentRow>> Update(Guid id, [FromBody] AdjustmentInput input, CancellationToken ct)
{
var a = await _db.CounterpartyAdjustments.FirstOrDefaultAsync(x => x.Id == id, ct);
if (a is null) return NotFound();
if (a.Status == MoneyTransactionStatus.Posted)
return Conflict(new { code = "already-posted", message = "Проведённую корректировку редактировать нельзя." });
if (input.Xmin is not null && input.Xmin.Value != 0)
_db.Entry(a).Property("xmin").OriginalValue = input.Xmin.Value;
a.Date = input.Date;
a.CounterpartyId = input.CounterpartyId;
a.Direction = input.Direction;
a.Amount = input.Amount;
a.Notes = input.Notes;
a.UpdatedAt = DateTime.UtcNow;
try { await _db.SaveChangesAsync(ct); }
catch (DbUpdateConcurrencyException) { return Conflict(new { code = "concurrency-conflict" }); }
return (await ReadOneAsync(id, ct))!;
}
[HttpDelete("{id:guid}"), RequiresPermission("MoneyEdit")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var a = await _db.CounterpartyAdjustments.FirstOrDefaultAsync(x => x.Id == id, ct);
if (a is null) return NotFound();
if (a.Status == MoneyTransactionStatus.Posted)
return Conflict(new { code = "already-posted", message = "Проведённую корректировку сначала отмените." });
_db.CounterpartyAdjustments.Remove(a);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), RequiresPermission("MoneyEdit")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
var a = await _db.CounterpartyAdjustments.FirstOrDefaultAsync(x => x.Id == id, ct);
if (a is null) return NotFound();
if (a.Status == MoneyTransactionStatus.Posted) return Conflict(new { code = "already-posted" });
a.Status = MoneyTransactionStatus.Posted;
a.PostedAt = DateTime.UtcNow;
a.PostedByUserId = CurrentUserId();
a.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/unpost"), RequiresPermission("MoneyEdit")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var a = await _db.CounterpartyAdjustments.FirstOrDefaultAsync(x => x.Id == id, ct);
if (a is null) return NotFound();
if (a.Status == MoneyTransactionStatus.Draft) return Conflict(new { code = "already-draft" });
a.Status = MoneyTransactionStatus.Draft;
a.PostedAt = null;
a.PostedByUserId = null;
a.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return NoContent();
}
private async Task<AdjustmentRow?> ReadOneAsync(Guid id, CancellationToken ct)
{
var a = await _db.CounterpartyAdjustments.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
if (a is null) return null;
var cp = await _db.Counterparties.AsNoTracking().Where(c => c.Id == a.CounterpartyId).Select(c => c.Name).FirstOrDefaultAsync(ct) ?? "";
var xmin = await _db.CounterpartyAdjustments.AsNoTracking().Where(x => x.Id == id)
.Select(x => Microsoft.EntityFrameworkCore.EF.Property<uint>(x, "xmin")).FirstAsync(ct);
return new AdjustmentRow(a.Id, a.Number, a.Date, a.CounterpartyId, cp, a.Direction, a.Amount, a.Notes, a.Status, a.PostedAt, xmin);
}
private Guid CurrentUserId()
{
var sub = User.FindFirst("sub")?.Value
?? User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return Guid.TryParse(sub, out var g) ? g : Guid.Empty;
}
}

View file

@ -0,0 +1,125 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Application.Common;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Money;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Money;
/// <summary>Справочник статей расходов. Системные (<c>IsSystem=true</c>) —
/// переименовать можно, удалить нет. Остальные — удалить можно, если ни
/// один <see cref="MoneyTransaction"/> не ссылается.</summary>
[ApiController]
[Authorize]
[Route("api/money/expense-items")]
public class ExpenseItemsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public ExpenseItemsController(AppDbContext db, ITenantContext tenant)
{
_db = db;
_tenant = tenant;
}
public record ExpenseItemRow(Guid Id, string Name, string? Code, bool IsSystem, bool IsActive, string? Notes, uint Xmin);
public record ExpenseItemInput(
[Required, MaxLength(200)] string Name,
[MaxLength(50)] string? Code,
[MaxLength(1000)] string? Notes,
bool IsActive = true,
uint? Xmin = null);
[HttpGet, RequiresPermission("MoneyView")]
public async Task<ActionResult<PagedResult<ExpenseItemRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] bool? isActive,
CancellationToken ct)
{
var q = _db.ExpenseItems.AsNoTracking().AsQueryable();
if (isActive is not null) q = q.Where(x => x.IsActive == isActive);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.Name.ToLower().Contains(s) || (x.Code != null && x.Code.ToLower().Contains(s)));
}
var total = await q.CountAsync(ct);
var items = await q.OrderBy(x => x.IsSystem ? 0 : 1).ThenBy(x => x.Name)
.Skip(req.Skip).Take(req.Take)
.Select(x => new ExpenseItemRow(x.Id, x.Name, x.Code, x.IsSystem, x.IsActive, x.Notes,
Microsoft.EntityFrameworkCore.EF.Property<uint>(x, "xmin")))
.ToListAsync(ct);
return new PagedResult<ExpenseItemRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpPost, RequiresPermission("MoneyEdit")]
public async Task<ActionResult<ExpenseItemRow>> Create([FromBody] ExpenseItemInput input, CancellationToken ct)
{
var e = new ExpenseItem
{
OrganizationId = _tenant.OrganizationId ?? Guid.Empty,
Name = input.Name.Trim(),
Code = string.IsNullOrWhiteSpace(input.Code) ? null : input.Code.Trim().ToUpperInvariant(),
Notes = input.Notes,
IsActive = input.IsActive,
IsSystem = false,
};
_db.ExpenseItems.Add(e);
try { await _db.SaveChangesAsync(ct); }
catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("duplicate") == true)
{
return Conflict(new { code = "expense-item-duplicate", message = "Статья с таким именем или кодом уже существует." });
}
return CreatedAtAction(nameof(Get), new { id = e.Id }, Read(e));
}
[HttpGet("{id:guid}"), RequiresPermission("MoneyView")]
public async Task<ActionResult<ExpenseItemRow>> Get(Guid id, CancellationToken ct)
{
var e = await _db.ExpenseItems.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return e is null ? NotFound() : Read(e);
}
[HttpPut("{id:guid}"), RequiresPermission("MoneyEdit")]
public async Task<ActionResult<ExpenseItemRow>> Update(Guid id, [FromBody] ExpenseItemInput input, CancellationToken ct)
{
var e = await _db.ExpenseItems.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
if (input.Xmin is not null && input.Xmin.Value != 0)
_db.Entry(e).Property("xmin").OriginalValue = input.Xmin.Value;
e.Name = input.Name.Trim();
e.Code = string.IsNullOrWhiteSpace(input.Code) ? null : input.Code.Trim().ToUpperInvariant();
e.Notes = input.Notes;
e.IsActive = input.IsActive;
e.UpdatedAt = DateTime.UtcNow;
try { await _db.SaveChangesAsync(ct); }
catch (DbUpdateConcurrencyException)
{
return Conflict(new { code = "concurrency-conflict" });
}
return Read(e);
}
[HttpDelete("{id:guid}"), RequiresPermission("MoneyEdit")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var e = await _db.ExpenseItems.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
if (e.IsSystem)
return Conflict(new { code = "expense-item-system", message = "Системную статью удалять нельзя." });
var inUse = await _db.MoneyTransactions.AnyAsync(m => m.ExpenseItemId == id, ct);
if (inUse)
return Conflict(new { code = "expense-item-in-use", message = "Статья используется в документах." });
_db.ExpenseItems.Remove(e);
await _db.SaveChangesAsync(ct);
return NoContent();
}
private static ExpenseItemRow Read(ExpenseItem e)
=> new(e.Id, e.Name, e.Code, e.IsSystem, e.IsActive, e.Notes, 0);
}

View file

@ -0,0 +1,290 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Application.Common;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Money;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Money;
/// <summary>Справочник «Счета и кассы» — CRUD. На любой счёт, у которого
/// есть хотя бы одно движение (<c>money_transactions.AccountId</c>) или
/// открытая смена (<c>cash_shifts.CashAccountId</c>), DELETE запрещён —
/// возвращаем 409 c кодом <c>account-in-use</c>; просим архивировать
/// (<c>IsActive=false</c>).</summary>
[ApiController]
[Authorize]
[Route("api/money/accounts")]
public class MoneyAccountsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public MoneyAccountsController(AppDbContext db, ITenantContext tenant)
{
_db = db;
_tenant = tenant;
}
public record MoneyAccountRow(
Guid Id, string Name, MoneyAccountKind Kind,
string? BankName, string? AccountNumber,
Guid? StoreId, string? StoreName,
Guid CurrencyId, string CurrencyCode,
decimal OpeningBalance, DateTime OpeningDate,
decimal CurrentBalance,
bool IsActive, bool IsDefault,
uint Xmin);
public record MoneyAccountInput(
[Required, MaxLength(200)] string Name,
MoneyAccountKind Kind,
[MaxLength(200)] string? BankName,
[MaxLength(100)] string? AccountNumber,
Guid? StoreId,
Guid CurrencyId,
[Range(-1e12, 1e12)] decimal OpeningBalance,
DateTime OpeningDate,
bool IsActive = true,
bool IsDefault = false,
uint? Xmin = null);
[HttpGet, RequiresPermission("MoneyView")]
public async Task<ActionResult<PagedResult<MoneyAccountRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] MoneyAccountKind? kind,
[FromQuery] bool? isActive,
CancellationToken ct)
{
var q = from a in _db.MoneyAccounts.AsNoTracking()
join cu in _db.Currencies on a.CurrencyId equals cu.Id
join st in _db.Stores on a.StoreId equals st.Id into stj
from st in stj.DefaultIfEmpty()
select new { a, cu, st };
if (kind is not null) q = q.Where(x => x.a.Kind == kind);
if (isActive is not null) q = q.Where(x => x.a.IsActive == isActive);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.a.Name.ToLower().Contains(s)
|| (x.a.BankName != null && x.a.BankName.ToLower().Contains(s))
|| (x.a.AccountNumber != null && x.a.AccountNumber.Contains(s)));
}
var total = await q.CountAsync(ct);
// Текущий остаток = Opening + Σ InKinds − Σ OutKinds (только Posted).
var ids = await q
.OrderBy(x => x.a.Kind).ThenBy(x => x.a.Name)
.Skip(req.Skip).Take(req.Take)
.Select(x => x.a.Id).ToListAsync(ct);
var balances = await _db.MoneyTransactions.AsNoTracking()
.Where(m => ids.Contains(m.AccountId) && m.Status == MoneyTransactionStatus.Posted)
.GroupBy(m => m.AccountId)
.Select(g => new
{
AccountId = g.Key,
InSum = g.Where(m =>
m.Kind == MoneyTransactionKind.PaymentIn
|| m.Kind == MoneyTransactionKind.CashIn
|| m.Kind == MoneyTransactionKind.TransferIn).Sum(m => (decimal?)m.Amount) ?? 0,
OutSum = g.Where(m =>
m.Kind == MoneyTransactionKind.PaymentOut
|| m.Kind == MoneyTransactionKind.CashOut
|| m.Kind == MoneyTransactionKind.TransferOut).Sum(m => (decimal?)m.Amount) ?? 0,
})
.ToDictionaryAsync(x => x.AccountId, ct);
var items = await q
.OrderBy(x => x.a.Kind).ThenBy(x => x.a.Name)
.Skip(req.Skip).Take(req.Take)
.Select(x => new { x.a, x.cu, StoreName = x.st != null ? x.st.Name : null })
.ToListAsync(ct);
var rows = items.Select(x =>
{
balances.TryGetValue(x.a.Id, out var b);
var current = x.a.OpeningBalance + (b?.InSum ?? 0) - (b?.OutSum ?? 0);
return new MoneyAccountRow(
x.a.Id, x.a.Name, x.a.Kind, x.a.BankName, x.a.AccountNumber,
x.a.StoreId, x.StoreName,
x.cu.Id, x.cu.Code,
x.a.OpeningBalance, x.a.OpeningDate, current,
x.a.IsActive, x.a.IsDefault,
Microsoft.EntityFrameworkCore.EF.Property<uint>(x.a, "xmin"));
}).ToList();
return new PagedResult<MoneyAccountRow>
{
Items = rows,
Total = total,
Page = req.Page,
PageSize = req.Take,
};
}
[HttpPost, RequiresPermission("MoneyEdit")]
public async Task<ActionResult<MoneyAccountRow>> Create([FromBody] MoneyAccountInput input, CancellationToken ct)
{
var val = Validate(input);
if (val is not null) return val;
// Cash должен быть привязан к Store (чтобы связать со сменой);
// Bank/Safe/Acquiring — опционально.
if (input.Kind == MoneyAccountKind.Cash && input.StoreId is null)
return Problem("Кассе требуется привязка к магазину (StoreId).",
statusCode: 400, type: "https://food-market.kz/errors/store-required");
var a = new MoneyAccount
{
OrganizationId = _tenant.OrganizationId ?? Guid.Empty,
Name = input.Name.Trim(),
Kind = input.Kind,
BankName = input.BankName?.Trim(),
AccountNumber = input.AccountNumber?.Trim(),
StoreId = input.StoreId,
CurrencyId = input.CurrencyId,
OpeningBalance = input.OpeningBalance,
OpeningDate = input.OpeningDate,
IsActive = input.IsActive,
IsDefault = false,
};
_db.MoneyAccounts.Add(a);
await _db.SaveChangesAsync(ct);
if (input.IsDefault)
await ApplyDefaultAsync(a.Id, a.Kind, ct);
return CreatedAtAction(nameof(Get), new { id = a.Id }, await ReadOneAsync(a.Id, ct));
}
[HttpGet("{id:guid}"), RequiresPermission("MoneyView")]
public async Task<ActionResult<MoneyAccountRow>> Get(Guid id, CancellationToken ct)
{
var row = await ReadOneAsync(id, ct);
return row is null ? NotFound() : row;
}
[HttpPut("{id:guid}"), RequiresPermission("MoneyEdit")]
public async Task<ActionResult<MoneyAccountRow>> Update(Guid id, [FromBody] MoneyAccountInput input, CancellationToken ct)
{
var val = Validate(input);
if (val is not null) return val;
var a = await _db.MoneyAccounts.FirstOrDefaultAsync(x => x.Id == id, ct);
if (a is null) return NotFound();
if (input.Xmin is not null && input.Xmin.Value != 0)
_db.Entry(a).Property("xmin").OriginalValue = input.Xmin.Value;
// OpeningBalance/OpeningDate после создания менять нельзя — ломает
// инвариант «CurrentBalance = Opening + Σ Posted».
if (a.OpeningBalance != input.OpeningBalance || a.OpeningDate != input.OpeningDate)
return Problem("Начальный остаток и дата после создания счёта не редактируются.",
statusCode: 409, type: "https://food-market.kz/errors/opening-balance-locked");
a.Name = input.Name.Trim();
a.Kind = input.Kind;
a.BankName = input.BankName?.Trim();
a.AccountNumber = input.AccountNumber?.Trim();
a.StoreId = input.StoreId;
a.CurrencyId = input.CurrencyId;
a.IsActive = input.IsActive;
a.UpdatedAt = DateTime.UtcNow;
try
{
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
return Conflict(new { code = "concurrency-conflict", message = "Счёт был изменён другим пользователем." });
}
if (input.IsDefault && !a.IsDefault)
await ApplyDefaultAsync(a.Id, a.Kind, ct);
return await ReadOneAsync(a.Id, ct) ?? (ActionResult<MoneyAccountRow>)NotFound();
}
[HttpDelete("{id:guid}"), RequiresPermission("MoneyEdit")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var a = await _db.MoneyAccounts.FirstOrDefaultAsync(x => x.Id == id, ct);
if (a is null) return NotFound();
var hasMovements = await _db.MoneyTransactions.AnyAsync(m => m.AccountId == id, ct);
var hasShifts = await _db.CashShifts.AnyAsync(s => s.CashAccountId == id || s.AcquiringAccountId == id, ct);
if (hasMovements || hasShifts)
return Conflict(new
{
code = "account-in-use",
message = "Счёт используется в документах или сменах — удалять нельзя, архивируйте его (IsActive=false).",
});
_db.MoneyAccounts.Remove(a);
await _db.SaveChangesAsync(ct);
return NoContent();
}
private ActionResult? Validate(MoneyAccountInput input)
{
if (input.Kind == MoneyAccountKind.Bank && string.IsNullOrWhiteSpace(input.AccountNumber))
return Problem("Для банковского счёта нужно указать номер счёта.",
statusCode: 400, type: "https://food-market.kz/errors/account-number-required");
return null;
}
private async Task ApplyDefaultAsync(Guid accountId, MoneyAccountKind kind, CancellationToken ct)
{
// Снимаем IsDefault с остальных счетов этого Kind и ставим на выбранный.
var others = await _db.MoneyAccounts
.Where(a => a.Kind == kind && a.Id != accountId && a.IsDefault)
.ToListAsync(ct);
foreach (var o in others) { o.IsDefault = false; o.UpdatedAt = DateTime.UtcNow; }
var me = await _db.MoneyAccounts.FirstAsync(a => a.Id == accountId, ct);
me.IsDefault = true;
me.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
}
private async Task<MoneyAccountRow?> ReadOneAsync(Guid id, CancellationToken ct)
{
var a = await _db.MoneyAccounts.AsNoTracking()
.Where(x => x.Id == id)
.Join(_db.Currencies, x => x.CurrencyId, c => c.Id, (x, c) => new { a = x, cu = c })
.FirstOrDefaultAsync(ct);
if (a is null) return null;
var storeName = a.a.StoreId is Guid sid
? await _db.Stores.AsNoTracking().Where(s => s.Id == sid).Select(s => s.Name).FirstOrDefaultAsync(ct)
: null;
var inSum = await _db.MoneyTransactions.AsNoTracking()
.Where(m => m.AccountId == a.a.Id && m.Status == MoneyTransactionStatus.Posted
&& (m.Kind == MoneyTransactionKind.PaymentIn
|| m.Kind == MoneyTransactionKind.CashIn
|| m.Kind == MoneyTransactionKind.TransferIn))
.SumAsync(m => (decimal?)m.Amount, ct) ?? 0;
var outSum = await _db.MoneyTransactions.AsNoTracking()
.Where(m => m.AccountId == a.a.Id && m.Status == MoneyTransactionStatus.Posted
&& (m.Kind == MoneyTransactionKind.PaymentOut
|| m.Kind == MoneyTransactionKind.CashOut
|| m.Kind == MoneyTransactionKind.TransferOut))
.SumAsync(m => (decimal?)m.Amount, ct) ?? 0;
var xmin = await _db.MoneyAccounts.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => Microsoft.EntityFrameworkCore.EF.Property<uint>(x, "xmin"))
.FirstAsync(ct);
return new MoneyAccountRow(
a.a.Id, a.a.Name, a.a.Kind, a.a.BankName, a.a.AccountNumber,
a.a.StoreId, storeName,
a.cu.Id, a.cu.Code,
a.a.OpeningBalance, a.a.OpeningDate,
a.a.OpeningBalance + inSum - outSum,
a.a.IsActive, a.a.IsDefault,
xmin);
}
}

View file

@ -0,0 +1,188 @@
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Money;
using foodmarket.Domain.Purchases;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Money;
/// <summary>Отчёты модуля «Деньги»: остатки по счетам, движение за период,
/// долги поставщикам. По образцу МойСклад <c>/report/money/plotseries</c> и
/// <c>/report/counterparty</c> (упрощённо).</summary>
[ApiController]
[Authorize]
[Route("api/money/reports")]
public class MoneyReportsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public MoneyReportsController(AppDbContext db, ITenantContext tenant)
{
_db = db;
_tenant = tenant;
}
public record AccountBalanceRow(Guid AccountId, string Name, MoneyAccountKind Kind,
string CurrencyCode, decimal OpeningBalance, decimal CurrentBalance, bool IsActive);
public record BalancesReport(IReadOnlyList<AccountBalanceRow> Accounts, decimal TotalByCurrencySum);
[HttpGet("balances"), RequiresPermission("MoneyView")]
public async Task<ActionResult<BalancesReport>> Balances(CancellationToken ct)
{
var accounts = await _db.MoneyAccounts.AsNoTracking()
.OrderBy(a => a.Kind).ThenBy(a => a.Name)
.ToListAsync(ct);
var ids = accounts.Select(a => a.Id).ToList();
var movements = await _db.MoneyTransactions.AsNoTracking()
.Where(m => ids.Contains(m.AccountId) && m.Status == MoneyTransactionStatus.Posted)
.GroupBy(m => new { m.AccountId, m.Kind })
.Select(g => new { g.Key.AccountId, g.Key.Kind, Sum = g.Sum(m => m.Amount) })
.ToListAsync(ct);
var currencies = await _db.Currencies.AsNoTracking().Where(c => ids.Any())
.ToDictionaryAsync(c => c.Id, c => c.Code, ct);
var rows = accounts.Select(a =>
{
var inSum = movements.Where(m => m.AccountId == a.Id &&
(m.Kind == MoneyTransactionKind.PaymentIn
|| m.Kind == MoneyTransactionKind.CashIn
|| m.Kind == MoneyTransactionKind.TransferIn)).Sum(m => m.Sum);
var outSum = movements.Where(m => m.AccountId == a.Id &&
(m.Kind == MoneyTransactionKind.PaymentOut
|| m.Kind == MoneyTransactionKind.CashOut
|| m.Kind == MoneyTransactionKind.TransferOut)).Sum(m => m.Sum);
return new AccountBalanceRow(a.Id, a.Name, a.Kind,
currencies.TryGetValue(a.CurrencyId, out var code) ? code : "",
a.OpeningBalance, a.OpeningBalance + inSum - outSum, a.IsActive);
}).ToList();
return new BalancesReport(rows, rows.Sum(r => r.CurrentBalance));
}
public record MoneyPoint(DateTime Date, decimal Credit, decimal Debit, decimal Balance);
public record MoneyMovementReport(DateTime From, DateTime To, decimal TotalCredit, decimal TotalDebit, IReadOnlyList<MoneyPoint> Series);
/// <summary>Движение денежных средств за период (аналог
/// <c>/report/money/plotseries</c>). Интервал — day/month.</summary>
[HttpGet("movement"), RequiresPermission("MoneyView")]
public async Task<ActionResult<MoneyMovementReport>> Movement(
[FromQuery] DateTime from,
[FromQuery] DateTime to,
[FromQuery] string interval = "day",
[FromQuery] Guid? accountId = null,
CancellationToken ct = default)
{
if (to <= from) return BadRequest(new { code = "bad-range", message = "to должен быть позже from" });
var fromUtc = DateTime.SpecifyKind(from, DateTimeKind.Utc);
var toUtc = DateTime.SpecifyKind(to, DateTimeKind.Utc);
var q = _db.MoneyTransactions.AsNoTracking()
.Where(m => m.Status == MoneyTransactionStatus.Posted && m.Date >= fromUtc && m.Date < toUtc);
if (accountId is not null) q = q.Where(m => m.AccountId == accountId);
var raw = await q.Select(m => new { m.Date, m.Kind, m.Amount }).ToListAsync(ct);
// Переводы не считаем ни «приходом», ни «расходом» орги — они
// внутри орги, не меняют совокупных денег. Фильтруем.
raw = raw.Where(r => r.Kind != MoneyTransactionKind.TransferIn
&& r.Kind != MoneyTransactionKind.TransferOut).ToList();
Func<DateTime, DateTime> bucket = interval switch
{
"month" => d => new DateTime(d.Year, d.Month, 1, 0, 0, 0, DateTimeKind.Utc),
_ => d => d.Date,
};
var groups = raw.GroupBy(r => bucket(r.Date))
.Select(g => new
{
Date = g.Key,
Credit = g.Where(x => x.Kind == MoneyTransactionKind.PaymentIn || x.Kind == MoneyTransactionKind.CashIn).Sum(x => x.Amount),
Debit = g.Where(x => x.Kind == MoneyTransactionKind.PaymentOut || x.Kind == MoneyTransactionKind.CashOut).Sum(x => x.Amount),
})
.OrderBy(g => g.Date)
.ToList();
var series = groups.Select(g => new MoneyPoint(g.Date, g.Credit, g.Debit, g.Credit - g.Debit)).ToList();
return new MoneyMovementReport(fromUtc, toUtc, series.Sum(s => s.Credit), series.Sum(s => s.Debit), series);
}
public record SupplierDebtRow(
Guid SupplierId, string SupplierName, string? Bin, string? Phone,
decimal SuppliesTotal, decimal ReturnsTotal, decimal PaymentsIn, decimal PaymentsOut, decimal AdjustmentDebt,
decimal Debt,
DateTime? OldestUnpaidDate, string? OldestUnpaidNumber);
public record SupplierDebtsReport(IReadOnlyList<SupplierDebtRow> Rows, decimal TotalDebt);
/// <summary>Долги поставщикам: Σ приёмок − Σ возвратов − Σ оплат + Σ корректировок.
/// Расчёт в C#, т.к. нужны джойны + aggregate'ы, которые EF транслирует неоптимально.</summary>
[HttpGet("supplier-debts"), RequiresPermission("MoneyView")]
public async Task<ActionResult<SupplierDebtsReport>> SupplierDebts(
[FromQuery] bool withDebtOnly = true,
CancellationToken ct = default)
{
var supplies = await _db.Supplies.AsNoTracking()
.Where(s => s.Status == SupplyStatus.Posted)
.GroupBy(s => s.SupplierId)
.Select(g => new
{
SupplierId = g.Key,
Total = g.Sum(s => s.Total),
Paid = g.Sum(s => s.PayedSum),
OldestUnpaid = g.Where(s => s.PayedSum < s.Total).OrderBy(s => s.Date).Select(s => new { s.Date, s.Number }).FirstOrDefault(),
})
.ToListAsync(ct);
var returns = await _db.SupplierReturns.AsNoTracking()
.Where(r => r.Status == SupplierReturnStatus.Posted)
.GroupBy(r => r.SupplierId)
.Select(g => new { SupplierId = g.Key, Total = g.Sum(r => r.Total), Paid = g.Sum(r => r.PayedSum) })
.ToListAsync(ct);
var adjustments = await _db.CounterpartyAdjustments.AsNoTracking()
.Where(a => a.Status == MoneyTransactionStatus.Posted)
.GroupBy(a => a.CounterpartyId)
.Select(g => new
{
CounterpartyId = g.Key,
WeOwe = g.Where(a => a.Direction == CounterpartyAdjustmentDirection.WeOwe).Sum(a => a.Amount),
TheyOwe = g.Where(a => a.Direction == CounterpartyAdjustmentDirection.TheyOwe).Sum(a => a.Amount),
})
.ToListAsync(ct);
var supplierIds = supplies.Select(s => s.SupplierId)
.Union(returns.Select(r => r.SupplierId))
.Union(adjustments.Select(a => a.CounterpartyId))
.Distinct().ToList();
var suppliers = await _db.Counterparties.AsNoTracking()
.Where(c => supplierIds.Contains(c.Id))
.Select(c => new { c.Id, c.Name, c.Bin, c.Phone })
.ToListAsync(ct);
var rows = new List<SupplierDebtRow>();
foreach (var sup in suppliers)
{
var s = supplies.FirstOrDefault(x => x.SupplierId == sup.Id);
var r = returns.FirstOrDefault(x => x.SupplierId == sup.Id);
var a = adjustments.FirstOrDefault(x => x.CounterpartyId == sup.Id);
// Долг = (приёмки − оплаченные приёмки) − (возвраты − полученные обратно деньги) + (корр: мы должны − они должны).
var debt = (s?.Total ?? 0) - (s?.Paid ?? 0)
- ((r?.Total ?? 0) - (r?.Paid ?? 0))
+ (a?.WeOwe ?? 0) - (a?.TheyOwe ?? 0);
if (withDebtOnly && Math.Abs(debt) < 0.0001m) continue;
rows.Add(new SupplierDebtRow(
sup.Id, sup.Name, sup.Bin, sup.Phone,
s?.Total ?? 0, r?.Total ?? 0,
r?.Paid ?? 0, s?.Paid ?? 0,
(a?.WeOwe ?? 0) - (a?.TheyOwe ?? 0),
debt,
s?.OldestUnpaid?.Date, s?.OldestUnpaid?.Number));
}
rows = rows.OrderByDescending(x => x.Debt).ToList();
return new SupplierDebtsReport(rows, rows.Sum(x => x.Debt));
}
}

View file

@ -0,0 +1,371 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Application.Common;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Application.Money;
using foodmarket.Domain.Money;
using foodmarket.Infrastructure.Money;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Money;
/// <summary>Платежи и ордера (единый reпозит на все виды <see cref="MoneyTransactionKind"/>
/// кроме «ящиковых» внутри смены — те через смену).
///
/// Создание — <c>POST /</c>, где <c>Kind</c> определяет тип документа.
/// Переводы между своими счетами делаются одним <c>POST /transfers</c>,
/// внутри атомарно создаётся пара TransferOut + TransferIn (с
/// LinkedTransactionId в обе стороны); проведение/отмена — тоже парная,
/// см. <see cref="IMoneyService"/>.
///
/// Проведение / отмена — <c>POST /{id}/post</c>, <c>POST /{id}/unpost</c>.
/// Привязка к приёмке/возврату — через поле <c>Links</c>, разноска суммы
/// по правилу «Σ LinkedSum ≤ Amount» (валидация в <see cref="MoneyService"/>).</summary>
[ApiController]
[Authorize]
[Route("api/money/transactions")]
public class MoneyTransactionsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly IMoneyService _money;
public MoneyTransactionsController(AppDbContext db, ITenantContext tenant, IMoneyService money)
{
_db = db;
_tenant = tenant;
_money = money;
}
public record MoneyTxLinkRow(Guid? SupplyId, string? SupplyNumber, Guid? SupplierReturnId, string? SupplierReturnNumber, decimal LinkedSum);
public record MoneyTxRow(
Guid Id, string Number, DateTime Date,
MoneyTransactionKind Kind, MoneyTransactionStatus Status,
Guid AccountId, string AccountName,
Guid? CounterpartyId, string? CounterpartyName,
Guid? ExpenseItemId, string? ExpenseItemName,
Guid CurrencyId, string CurrencyCode,
decimal Amount, string? Purpose, string? Notes,
Guid? LinkedTransactionId,
DateTime? PostedAt,
IReadOnlyList<MoneyTxLinkRow> Links,
uint Xmin);
public record MoneyTxLinkInput(Guid? SupplyId, Guid? SupplierReturnId, [Range(0, 1e12)] decimal LinkedSum);
public record MoneyTxInput(
DateTime Date,
MoneyTransactionKind Kind,
Guid AccountId,
Guid? CounterpartyId,
Guid? ExpenseItemId,
Guid CurrencyId,
[Range(0, 1e12)] decimal Amount,
[MaxLength(500)] string? Purpose,
[MaxLength(1000)] string? Notes,
IReadOnlyList<MoneyTxLinkInput>? Links = null,
uint? Xmin = null);
[HttpGet, RequiresPermission("MoneyView")]
public async Task<ActionResult<PagedResult<MoneyTxRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] MoneyTransactionKind? kind,
[FromQuery] MoneyTransactionStatus? status,
[FromQuery] Guid? accountId,
[FromQuery] Guid? counterpartyId,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
CancellationToken ct)
{
var q = _db.MoneyTransactions.AsNoTracking().AsQueryable();
if (kind is not null) q = q.Where(x => x.Kind == kind);
if (status is not null) q = q.Where(x => x.Status == status);
if (accountId is not null) q = q.Where(x => x.AccountId == accountId);
if (counterpartyId is not null) q = q.Where(x => x.CounterpartyId == counterpartyId);
if (from is not null) q = q.Where(x => x.Date >= from);
if (to is not null) q = q.Where(x => x.Date < to);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.Number.ToLower().Contains(s)
|| (x.Purpose != null && x.Purpose.ToLower().Contains(s)));
}
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.Date).ThenByDescending(x => x.Number)
.Skip(req.Skip).Take(req.Take)
.Select(x => new
{
x.Id, x.Number, x.Date, x.Kind, x.Status,
x.AccountId, AccountName = _db.MoneyAccounts.Where(a => a.Id == x.AccountId).Select(a => a.Name).FirstOrDefault() ?? "",
x.CounterpartyId, CounterpartyName = x.CounterpartyId == null ? null : _db.Counterparties.Where(c => c.Id == x.CounterpartyId).Select(c => c.Name).FirstOrDefault(),
x.ExpenseItemId, ExpenseItemName = x.ExpenseItemId == null ? null : _db.ExpenseItems.Where(e => e.Id == x.ExpenseItemId).Select(e => e.Name).FirstOrDefault(),
x.CurrencyId, CurrencyCode = _db.Currencies.Where(c => c.Id == x.CurrencyId).Select(c => c.Code).FirstOrDefault() ?? "",
x.Amount, x.Purpose, x.Notes,
x.LinkedTransactionId, x.PostedAt,
Xmin = Microsoft.EntityFrameworkCore.EF.Property<uint>(x, "xmin"),
})
.ToListAsync(ct);
var ids = rows.Select(r => r.Id).ToList();
var links = await _db.MoneyTransactionLinks.AsNoTracking()
.Where(l => ids.Contains(l.TransactionId))
.Select(l => new
{
l.TransactionId,
l.SupplyId, SupplyNumber = l.Supply != null ? l.Supply.Number : null,
l.SupplierReturnId, SRNumber = l.SupplierReturn != null ? l.SupplierReturn.Number : null,
l.LinkedSum,
})
.ToListAsync(ct);
var items = rows.Select(r => new MoneyTxRow(
r.Id, r.Number, r.Date, r.Kind, r.Status,
r.AccountId, r.AccountName, r.CounterpartyId, r.CounterpartyName,
r.ExpenseItemId, r.ExpenseItemName,
r.CurrencyId, r.CurrencyCode,
r.Amount, r.Purpose, r.Notes,
r.LinkedTransactionId, r.PostedAt,
links.Where(l => l.TransactionId == r.Id)
.Select(l => new MoneyTxLinkRow(l.SupplyId, l.SupplyNumber, l.SupplierReturnId, l.SRNumber, l.LinkedSum))
.ToList(),
r.Xmin)).ToList();
return new PagedResult<MoneyTxRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}"), RequiresPermission("MoneyView")]
public async Task<ActionResult<MoneyTxRow>> Get(Guid id, CancellationToken ct)
{
var row = await ReadOneAsync(id, ct);
return row is null ? NotFound() : row;
}
[HttpPost, RequiresPermission("MoneyEdit")]
public async Task<ActionResult<MoneyTxRow>> Create([FromBody] MoneyTxInput input, CancellationToken ct)
{
if (input.Kind == MoneyTransactionKind.TransferIn || input.Kind == MoneyTransactionKind.TransferOut)
return Problem("Переводы между счетами создавайте через /api/money/transfers.",
statusCode: 400, type: "https://food-market.kz/errors/use-transfers-endpoint");
if (input.Amount <= 0)
return Problem("Сумма должна быть больше нуля.", statusCode: 400,
type: "https://food-market.kz/errors/amount-must-be-positive");
if ((input.Kind == MoneyTransactionKind.PaymentOut || input.Kind == MoneyTransactionKind.CashOut)
&& input.ExpenseItemId is null && (input.Links is null || input.Links.Count == 0))
return Problem("Расходный документ без привязок к документам должен иметь статью расхода.",
statusCode: 400, type: "https://food-market.kz/errors/expense-item-required");
var orgId = _tenant.OrganizationId ?? Guid.Empty;
var mt = new MoneyTransaction
{
OrganizationId = orgId,
Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct),
Date = input.Date,
Kind = input.Kind,
Status = MoneyTransactionStatus.Draft,
AccountId = input.AccountId,
CounterpartyId = input.CounterpartyId,
ExpenseItemId = input.ExpenseItemId,
CurrencyId = input.CurrencyId,
Amount = input.Amount,
Purpose = string.IsNullOrWhiteSpace(input.Purpose) ? null : input.Purpose.Trim(),
Notes = input.Notes,
};
foreach (var l in input.Links ?? Array.Empty<MoneyTxLinkInput>())
{
// CHECK в БД поймает некорректное; продублируем валидацией для внятной ошибки.
if ((l.SupplyId is null) == (l.SupplierReturnId is null))
return Problem("В каждой связи должен быть ровно один из SupplyId / SupplierReturnId.",
statusCode: 400, type: "https://food-market.kz/errors/link-single-target");
mt.Links.Add(new MoneyTransactionLink
{
OrganizationId = orgId,
SupplyId = l.SupplyId,
SupplierReturnId = l.SupplierReturnId,
LinkedSum = l.LinkedSum,
});
}
_db.MoneyTransactions.Add(mt);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = mt.Id }, await ReadOneAsync(mt.Id, ct));
}
[HttpPut("{id:guid}"), RequiresPermission("MoneyEdit")]
public async Task<ActionResult<MoneyTxRow>> Update(Guid id, [FromBody] MoneyTxInput input, CancellationToken ct)
{
var mt = await _db.MoneyTransactions.Include(m => m.Links).FirstOrDefaultAsync(m => m.Id == id, ct);
if (mt is null) return NotFound();
if (mt.Status == MoneyTransactionStatus.Posted)
return Conflict(new { code = "already-posted", message = "Проведённый документ нельзя редактировать. Отмените проведение сначала." });
if (input.Xmin is not null && input.Xmin.Value != 0)
_db.Entry(mt).Property("xmin").OriginalValue = input.Xmin.Value;
mt.Date = input.Date;
mt.AccountId = input.AccountId;
mt.CounterpartyId = input.CounterpartyId;
mt.ExpenseItemId = input.ExpenseItemId;
mt.CurrencyId = input.CurrencyId;
mt.Amount = input.Amount;
mt.Purpose = string.IsNullOrWhiteSpace(input.Purpose) ? null : input.Purpose.Trim();
mt.Notes = input.Notes;
mt.UpdatedAt = DateTime.UtcNow;
_db.MoneyTransactionLinks.RemoveRange(mt.Links);
mt.Links.Clear();
foreach (var l in input.Links ?? Array.Empty<MoneyTxLinkInput>())
{
if ((l.SupplyId is null) == (l.SupplierReturnId is null))
return Problem("В каждой связи должен быть ровно один из SupplyId / SupplierReturnId.",
statusCode: 400, type: "https://food-market.kz/errors/link-single-target");
mt.Links.Add(new MoneyTransactionLink
{
OrganizationId = mt.OrganizationId,
SupplyId = l.SupplyId,
SupplierReturnId = l.SupplierReturnId,
LinkedSum = l.LinkedSum,
});
}
try { await _db.SaveChangesAsync(ct); }
catch (DbUpdateConcurrencyException)
{
return Conflict(new { code = "concurrency-conflict" });
}
return (await ReadOneAsync(id, ct))!;
}
[HttpDelete("{id:guid}"), RequiresPermission("MoneyEdit")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var mt = await _db.MoneyTransactions.FirstOrDefaultAsync(m => m.Id == id, ct);
if (mt is null) return NotFound();
if (mt.Status == MoneyTransactionStatus.Posted)
return Conflict(new { code = "already-posted", message = "Проведённый документ нельзя удалить." });
_db.MoneyTransactions.Remove(mt);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), RequiresPermission("MoneyEdit")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
var userId = CurrentUserId();
var r = await _money.PostAsync(id, userId, ct);
return MapResult(r);
}
[HttpPost("{id:guid}/unpost"), RequiresPermission("MoneyEdit")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var userId = CurrentUserId();
var r = await _money.UnpostAsync(id, userId, ct);
return MapResult(r);
}
public record TransferInput(
DateTime Date,
Guid FromAccountId,
Guid ToAccountId,
[Range(0.0001, 1e12)] decimal Amount,
[MaxLength(500)] string? Purpose);
[HttpPost("/api/money/transfers"), RequiresPermission("MoneyEdit")]
public async Task<ActionResult<object>> Transfer([FromBody] TransferInput input, CancellationToken ct)
{
if (input.FromAccountId == input.ToAccountId)
return Problem("Нельзя перевести на тот же счёт.", statusCode: 400,
type: "https://food-market.kz/errors/transfer-same-account");
var orgId = _tenant.OrganizationId ?? Guid.Empty;
var from = await _db.MoneyAccounts.FirstOrDefaultAsync(a => a.Id == input.FromAccountId, ct);
var to = await _db.MoneyAccounts.FirstOrDefaultAsync(a => a.Id == input.ToAccountId, ct);
if (from is null || to is null) return NotFound();
if (from.CurrencyId != to.CurrencyId)
return Problem("Перевод между счетами разной валюты не поддерживается в простом режиме.",
statusCode: 400, type: "https://food-market.kz/errors/transfer-currency-mismatch");
var outTx = new MoneyTransaction
{
OrganizationId = orgId,
Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct),
Date = input.Date,
Kind = MoneyTransactionKind.TransferOut,
Status = MoneyTransactionStatus.Draft,
AccountId = from.Id,
CurrencyId = from.CurrencyId,
Amount = input.Amount,
Purpose = input.Purpose,
};
var inTx = new MoneyTransaction
{
OrganizationId = orgId,
Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct),
Date = input.Date,
Kind = MoneyTransactionKind.TransferIn,
Status = MoneyTransactionStatus.Draft,
AccountId = to.Id,
CurrencyId = to.CurrencyId,
Amount = input.Amount,
Purpose = input.Purpose,
};
_db.MoneyTransactions.AddRange(outTx, inTx);
await _db.SaveChangesAsync(ct);
// Теперь свяжем парой — после первого save появились Id.
outTx.LinkedTransactionId = inTx.Id;
inTx.LinkedTransactionId = outTx.Id;
await _db.SaveChangesAsync(ct);
return Ok(new { outId = outTx.Id, inId = inTx.Id, outNumber = outTx.Number, inNumber = inTx.Number });
}
private Guid CurrentUserId()
{
var sub = User.FindFirst("sub")?.Value
?? User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return Guid.TryParse(sub, out var g) ? g : Guid.Empty;
}
private IActionResult MapResult(MoneyServiceResult r) => r.Status switch
{
MoneyServiceStatus.Ok => NoContent(),
MoneyServiceStatus.NotFound => NotFound(),
MoneyServiceStatus.AlreadyPosted => Conflict(new { code = r.ErrorCode, message = r.Message }),
MoneyServiceStatus.AlreadyDraft => Conflict(new { code = r.ErrorCode, message = r.Message }),
MoneyServiceStatus.OverpaidDocument => Conflict(new { code = r.ErrorCode, message = r.Message }),
MoneyServiceStatus.LinkedSumMismatch => BadRequest(new { code = r.ErrorCode, message = r.Message }),
MoneyServiceStatus.WrongCurrency => BadRequest(new { code = r.ErrorCode, message = r.Message }),
MoneyServiceStatus.AccountInactive => Conflict(new { code = r.ErrorCode, message = r.Message }),
_ => BadRequest(new { code = r.ErrorCode, message = r.Message }),
};
private async Task<MoneyTxRow?> ReadOneAsync(Guid id, CancellationToken ct)
{
var mt = await _db.MoneyTransactions.AsNoTracking()
.Include(x => x.Links).ThenInclude(l => l.Supply)
.Include(x => x.Links).ThenInclude(l => l.SupplierReturn)
.FirstOrDefaultAsync(x => x.Id == id, ct);
if (mt is null) return null;
var account = await _db.MoneyAccounts.AsNoTracking().Where(a => a.Id == mt.AccountId).Select(a => a.Name).FirstAsync(ct);
var counterparty = mt.CounterpartyId is null ? null :
await _db.Counterparties.AsNoTracking().Where(c => c.Id == mt.CounterpartyId).Select(c => c.Name).FirstOrDefaultAsync(ct);
var exp = mt.ExpenseItemId is null ? null :
await _db.ExpenseItems.AsNoTracking().Where(e => e.Id == mt.ExpenseItemId).Select(e => e.Name).FirstOrDefaultAsync(ct);
var currency = await _db.Currencies.AsNoTracking().Where(c => c.Id == mt.CurrencyId).Select(c => c.Code).FirstAsync(ct);
var xmin = await _db.MoneyTransactions.AsNoTracking().Where(x => x.Id == id)
.Select(x => Microsoft.EntityFrameworkCore.EF.Property<uint>(x, "xmin")).FirstAsync(ct);
return new MoneyTxRow(
mt.Id, mt.Number, mt.Date, mt.Kind, mt.Status,
mt.AccountId, account, mt.CounterpartyId, counterparty,
mt.ExpenseItemId, exp,
mt.CurrencyId, currency,
mt.Amount, mt.Purpose, mt.Notes,
mt.LinkedTransactionId, mt.PostedAt,
mt.Links.Select(l => new MoneyTxLinkRow(l.SupplyId, l.Supply?.Number, l.SupplierReturnId, l.SupplierReturn?.Number, l.LinkedSum)).ToList(),
xmin);
}
}

View file

@ -36,7 +36,18 @@ public record SupplyListRow(
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
decimal Total, int LineCount,
DateTime? PostedAt);
DateTime? PostedAt,
decimal PayedSum, string PaymentStatus);
/// <summary>Статус оплаты приёмки — вычисляемая строка:
/// "unpaid" (0), "partial" (0 &lt; payed &lt; total), "paid" (payed ≥ total).
/// Фронт показывает цветной бейдж. Расчёт здесь — простейший порог.</summary>
private static string ComputePaymentStatus(decimal total, decimal payed)
{
if (total <= 0) return "paid";
if (payed <= 0) return "unpaid";
return payed + 0.0001m >= total ? "paid" : "partial";
}
public record SupplyLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,
@ -54,7 +65,8 @@ public record SupplyDto(
string? Notes,
decimal Total, DateTime? PostedAt,
uint Xmin,
IReadOnlyList<SupplyLineDto> Lines);
IReadOnlyList<SupplyLineDto> Lines,
decimal PayedSum, string PaymentStatus);
public record SupplyLineInput(
Guid ProductId,
@ -77,6 +89,7 @@ public record SupplyInput(
[FromQuery] SupplyStatus? status,
[FromQuery] Guid? storeId,
[FromQuery] Guid? supplierId,
[FromQuery] string? paymentStatus,
CancellationToken ct)
{
var q = from s in _db.Supplies.AsNoTracking()
@ -93,6 +106,14 @@ public record SupplyInput(
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.s.Number.ToLower().Contains(s) || x.cp.Name.ToLower().Contains(s));
}
// Фильтр по статусу оплаты (unpaid/partial/paid). "all"/null — без фильтра.
q = paymentStatus switch
{
"unpaid" => q.Where(x => x.s.Status == SupplyStatus.Posted && x.s.PayedSum <= 0),
"partial" => q.Where(x => x.s.Status == SupplyStatus.Posted && x.s.PayedSum > 0 && x.s.PayedSum < x.s.Total),
"paid" => q.Where(x => x.s.Status == SupplyStatus.Posted && x.s.PayedSum >= x.s.Total),
_ => q,
};
var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
@ -110,17 +131,27 @@ public record SupplyInput(
("date", false) => q.OrderBy(x => x.s.Date).ThenBy(x => x.s.Number),
_ => q.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number),
};
var items = await q
var raw = await q
.Skip(req.Skip).Take(req.Take)
.Select(x => new SupplyListRow(
.Select(x => new
{
x.s.Id, x.s.Number, x.s.Date, x.s.Status,
x.cp.Id, x.cp.Name,
x.st.Id, x.st.Name,
x.cu.Id, x.cu.Code,
SupplierId = x.cp.Id, SupplierName = x.cp.Name,
StoreId = x.st.Id, StoreName = x.st.Name,
CurrencyId = x.cu.Id, CurrencyCode = x.cu.Code,
x.s.Total,
x.s.Lines.Count,
x.s.PostedAt))
LineCount = x.s.Lines.Count,
x.s.PostedAt,
x.s.PayedSum,
})
.ToListAsync(ct);
var items = raw.Select(x => new SupplyListRow(
x.Id, x.Number, x.Date, x.Status,
x.SupplierId, x.SupplierName,
x.StoreId, x.StoreName,
x.CurrencyId, x.CurrencyCode,
x.Total, x.LineCount, x.PostedAt,
x.PayedSum, ComputePaymentStatus(x.Total, x.PayedSum))).ToList();
return new PagedResult<SupplyListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
@ -617,6 +648,7 @@ orderby l.SortOrder
row.s.Notes,
row.s.Total, row.s.PostedAt,
row.Xmin,
lines);
lines,
row.s.PayedSum, ComputePaymentStatus(row.s.Total, row.s.PayedSum));
}
}

View file

@ -569,6 +569,28 @@ private async Task<IActionResult> PostCoreAsync(Guid id, CancellationToken ct)
if (sale.Status == RetailSaleStatus.Posted) return Conflict(new { error = "Чек уже проведён." });
if (sale.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести пустой чек." });
// Sprint 33: продажи без открытой смены на точке — запрещаем. Исключение
// только для операций без RetailPoint (bulk-импорт, тесты, legacy чеки
// до внедрения модуля денег).
if (sale.RetailPointId is Guid rpId)
{
var openShift = await _db.CashShifts
.Where(cs => cs.RetailPointId == rpId
&& cs.Status == foodmarket.Domain.Money.CashShiftStatus.Open)
.Select(cs => cs.Id)
.FirstOrDefaultAsync(ct);
if (openShift == Guid.Empty)
{
return Conflict(new
{
error = "На этой точке продаж не открыта кассовая смена. Откройте смену и попробуйте снова.",
code = "cash-shift-required",
});
}
// Привязываем чек к открытой смене.
sale.CashShiftId = openShift;
}
// Для возврата — отдельная ветка обработки (см. PostReturnAsync ниже).
if (sale.IsReturn) return await PostReturnAsync(sale, ct);

View file

@ -436,6 +436,10 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
// Inventory
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
// Sprint 33: модуль «Деньги»
builder.Services.AddScoped<foodmarket.Application.Money.IMoneyService,
foodmarket.Infrastructure.Money.MoneyService>();
// Hangfire — фоновые джобы и UI-дашборд. Хранилище — наш Postgres
// (тот же ConnectionString:Default). Запускаем фактический сервер только
// когда приложение действительно работает (не в тестах): чтобы тестовые

View file

@ -1,4 +1,5 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Money;
using foodmarket.Domain.Organizations;
using foodmarket.Infrastructure.Identity;
using foodmarket.Infrastructure.Persistence;
@ -265,6 +266,10 @@ private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, Ca
ProductsView = true,
SuppliesView = true, SuppliesEdit = true, SuppliesPost = true,
StocksView = true,
// Кладовщику деньги не положены — только видимость оплаченности
// приёмки через SuppliesView (payedSum в DTO). MoneyView=false
// здесь, этим флаг «кладовщик без доступа к деньгам» (простой
// режим, п.7 ТЗ модуля).
},
};
var cashier = new EmployeeRole
@ -279,11 +284,40 @@ private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, Ca
StocksView = true,
RetailSalesOperate = true,
// RetailSalesRefund по умолчанию false — админ включит при необходимости
// Кассиру нужна возможность открыть/закрыть свою смену и
// вносить/выплачивать деньги в ней. Чужие смены и движения
// по банковским счетам ему недоступны (контроллер фильтрует).
CashShiftOperate = true,
MoneyView = true,
},
};
db.EmployeeRoles.AddRange(admin, keeper, cashier);
await db.SaveChangesAsync(ct);
await SeedExpenseItemsAsync(db, orgId, ct);
}
/// <summary>Системные статьи расходов для новой организации — пять по
/// умолчанию (из ТЗ модуля «Деньги»). Их можно переименовать, но
/// нельзя удалить (<see cref="ExpenseItem.IsSystem"/>=true), чтобы
/// старые документы не остались с «висящим» FK.</summary>
private static async Task SeedExpenseItemsAsync(AppDbContext db, Guid orgId, CancellationToken ct)
{
var anyItem = await db.ExpenseItems.IgnoreQueryFilters()
.AnyAsync(e => e.OrganizationId == orgId, ct);
if (anyItem) return;
var items = new[]
{
new ExpenseItem { OrganizationId = orgId, Name = "Закупка товара", Code = "PURCHASE", IsSystem = true, IsActive = true },
new ExpenseItem { OrganizationId = orgId, Name = "Аренда", Code = "RENT", IsSystem = true, IsActive = true },
new ExpenseItem { OrganizationId = orgId, Name = "Зарплата", Code = "SALARY", IsSystem = true, IsActive = true },
new ExpenseItem { OrganizationId = orgId, Name = "Коммунальные", Code = "UTILITIES", IsSystem = true, IsActive = true },
new ExpenseItem { OrganizationId = orgId, Name = "Прочее", Code = "OTHER", IsSystem = true, IsActive = true },
};
db.ExpenseItems.AddRange(items);
await db.SaveChangesAsync(ct);
}
/// <summary>Канонический набор 5 глобальных единиц измерения (Phase5c).

View file

@ -0,0 +1,69 @@
using foodmarket.Domain.Money;
namespace foodmarket.Application.Money;
/// <summary>Единый API модуля «Деньги». Контроллеры дёргают только эти
/// методы — внутри сервиса сидит вся бизнес-логика: пересчёт
/// <c>Supply.PayedSum</c>, парная проводка переводов, расчёт expected
/// cash для смены, применение статусов. Все методы Serializable-safe
/// (транзакция внутри — одна на операцию).</summary>
public interface IMoneyService
{
/// <summary>Провести готовый документ: смена Status Draft→Posted,
/// пересчёт PayedSum на связанных документах. Пустой список
/// <c>Links</c> допустим (аналог noClosingDocs=true). Для TransferOut
/// — атомарно создаёт/проводит парный TransferIn.</summary>
Task<MoneyServiceResult> PostAsync(Guid transactionId, Guid currentUserId, CancellationToken ct);
/// <summary>Отменить проведение. Пересчёт PayedSum в сторону уменьшения.
/// После Unpost документ можно удалить или снова отредактировать и
/// провести заново.</summary>
Task<MoneyServiceResult> UnpostAsync(Guid transactionId, Guid currentUserId, CancellationToken ct);
/// <summary>Открыть смену на точке. Падает 409 если на RetailPoint
/// уже есть открытая смена.</summary>
Task<CashShiftOpenResult> OpenShiftAsync(Guid retailPointId, Guid cashAccountId,
Guid? acquiringAccountId, decimal openingFloat, Guid cashierUserId, CancellationToken ct);
/// <summary>Закрыть смену: вычислить snapshot'ы продаж/возвратов,
/// expected cash, разницу. Запрещено если уже закрыта.</summary>
Task<CashShiftCloseResult> CloseShiftAsync(Guid shiftId, decimal actualCash,
Guid closedByUserId, string? notes, CancellationToken ct);
/// <summary>Добавить внесение/выплату внутри открытой смены.</summary>
Task<CashDrawerOperationResult> AddDrawerOperationAsync(Guid shiftId,
CashDrawerDirection direction, decimal amount, string reason,
Guid byUserId, CancellationToken ct);
}
public enum MoneyServiceStatus
{
Ok = 0,
NotFound = 1,
AlreadyPosted = 2,
AlreadyDraft = 3,
ValidationFailed = 4,
Conflict = 5,
ShiftNotOpen = 6,
ShiftAlreadyOpen = 7,
OverpaidDocument = 8,
LinkedSumMismatch = 9,
WrongCurrency = 10,
AccountInactive = 11,
}
public record MoneyServiceResult(MoneyServiceStatus Status, string? ErrorCode = null, string? Message = null)
{
public static MoneyServiceResult Ok() => new(MoneyServiceStatus.Ok);
public static MoneyServiceResult Fail(MoneyServiceStatus s, string code, string msg)
=> new(s, code, msg);
}
public record CashShiftOpenResult(MoneyServiceStatus Status, Guid? ShiftId, string? Number,
string? ErrorCode, string? Message);
public record CashShiftCloseResult(MoneyServiceStatus Status, decimal ExpectedCash,
decimal CashDifference, string? ErrorCode, string? Message);
public record CashDrawerOperationResult(MoneyServiceStatus Status, Guid? OperationId,
string? ErrorCode, string? Message);

View file

@ -0,0 +1,118 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Money;
/// <summary>Статус кассовой смены. От МойСклада берём идею, но упрощённо:
/// Open — ящик открыт, можно продавать; Closed — закрыт кассиром с указанием
/// фактических денег в ящике, пересчитан expected vs actual. Z-отчёт при
/// закрытии — просмотр и печать по этой записи.</summary>
public enum CashShiftStatus
{
Open = 1,
Closed = 2,
}
/// <summary>Розничная смена кассы — аналог МойСкладовского
/// <c>retailshift</c>. Одна открытая смена per <see cref="RetailPointId"/>:
/// повторный open на той же точке падает в 409. Все продажи этой точки
/// в периоде [OpenedAt, ClosedAt ?? now] привязываются к этой смене
/// (<c>RetailSale.CashShiftId</c>, добавляем в Phase2 миграции).
///
/// При закрытии кассир вводит <see cref="ActualCash"/> — фактическую
/// сумму в ящике. Expected считается из <see cref="OpeningFloat"/> +
/// Σ cash-операций смены (продажи − возвраты + внесения − выплаты).
/// Разница в <see cref="CashDifference"/> подсвечивается в UI;
/// отрицательная = недостача, положительная = излишек. Z-отчёт — просто
/// печатный вид этой записи + свои аггрегаты по картам/QR.</summary>
public class CashShift : TenantEntity, IVersionedEntity
{
public uint Xmin { get; set; }
public string Number { get; set; } = "";
public CashShiftStatus Status { get; set; } = CashShiftStatus.Open;
/// <summary>Точка продаж, на которой открыта смена.</summary>
public Guid RetailPointId { get; set; }
public RetailPoint RetailPoint { get; set; } = null!;
/// <summary>Счёт «касса» этой точки — на нём отражается физический нал
/// смены (внесения/выплаты, продажи наличными). Выбирается из
/// <see cref="MoneyAccount"/> где Kind=Cash + StoreId совпадает с
/// RetailPoint.StoreId. Если несколько касс — кассир выбирает в
/// момент открытия.</summary>
public Guid CashAccountId { get; set; }
public MoneyAccount CashAccount { get; set; } = null!;
/// <summary>Счёт «эквайринг» для карточной выручки. Опционально —
/// если магазин не берёт карты, смена работает только с наличными.</summary>
public Guid? AcquiringAccountId { get; set; }
public MoneyAccount? AcquiringAccount { get; set; }
public Guid CashierUserId { get; set; }
public DateTime OpenedAt { get; set; } = DateTime.UtcNow;
public DateTime? ClosedAt { get; set; }
public Guid? ClosedByUserId { get; set; }
/// <summary>«Разменник» на начало смены — сумма наличных, которую
/// кассир положил в ящик перед первой продажей (например, 20 000₸
/// разменными купюрами). Берётся из показателя предыдущей закрытой
/// смены, кассир может скорректировать.</summary>
public decimal OpeningFloat { get; set; }
/// <summary>Снапшоты на момент закрытия. До Close все == 0.</summary>
public decimal SalesCash { get; set; }
public decimal SalesCard { get; set; }
public decimal ReturnsCash { get; set; }
public decimal ReturnsCard { get; set; }
public decimal DrawerIn { get; set; } // Σ внесений
public decimal DrawerOut { get; set; } // Σ выплат
public decimal ExpectedCash { get; set; }
/// <summary>Фактическая сумма наличных в ящике при закрытии (вводит
/// кассир). Null пока смена открыта.</summary>
public decimal? ActualCash { get; set; }
/// <summary>ActualCash − ExpectedCash. Знак: &lt;0 — недостача,
/// &gt;0 — излишек. Снапшот на момент закрытия.</summary>
public decimal CashDifference { get; set; }
public string? Notes { get; set; }
public ICollection<CashDrawerOperation> DrawerOperations { get; set; } = new List<CashDrawerOperation>();
}
/// <summary>Направление ящикового движения кассы. In — кассир положил
/// деньги в ящик («внесение», «разменять из сейфа»). Out — выдал
/// («сдача инкассатору», «выдал курьеру»). Логика расчёта смены
/// соответствует МойСкладовским <c>retaildrawercashin/retaildrawercashout</c>.</summary>
public enum CashDrawerDirection
{
In = 1,
Out = 2,
}
/// <summary>Внесение/выплата внутри смены — простая запись, которую может
/// сделать только тот кассир, у которого открыта смена, и только пока
/// она открыта. После Close CashShift становится неизменяемой — операции
/// привязаны намертво и попадают в Z-отчёт.</summary>
public class CashDrawerOperation : TenantEntity, IVersionedEntity
{
public uint Xmin { get; set; }
public Guid CashShiftId { get; set; }
public CashShift CashShift { get; set; } = null!;
public CashDrawerDirection Direction { get; set; }
public decimal Amount { get; set; }
/// <summary>Причина — обязательный свободный текст («Размен из сейфа»,
/// «Инкассация», «Выдал курьеру за доставку»). Пусто не принимаем.</summary>
public string Reason { get; set; } = null!;
public DateTime At { get; set; } = DateTime.UtcNow;
public Guid ByUserId { get; set; }
}

View file

@ -0,0 +1,45 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Money;
/// <summary>Направление корректировки взаиморасчётов: WeOwe — мы должны
/// контрагенту (увеличивает долг нам), TheyOwe — контрагент должен нам
/// (уменьшает долг). Соответствует MoySkladовскому
/// <c>counterpartyadjustment</c>, где знак выражается через <c>sum</c>.</summary>
public enum CounterpartyAdjustmentDirection
{
WeOwe = 1,
TheyOwe = 2,
}
/// <summary>Корректировка взаиморасчётов — для стартовых долгов и ручных
/// исправлений. В простом режиме главное применение: «мы начинаем работать
/// в системе, но с поставщиком Х у нас уже есть долг 500 000₸».
///
/// Проведённая корректировка влияет на отчёт «Долги поставщикам» (прибавляет
/// или вычитает), на баланс счетов НЕ влияет — это чисто учётный документ,
/// без денежного движения.</summary>
public class CounterpartyAdjustment : TenantEntity, IVersionedEntity
{
public uint Xmin { get; set; }
public string Number { get; set; } = "";
public DateTime Date { get; set; } = DateTime.UtcNow;
public Guid CounterpartyId { get; set; }
public Counterparty Counterparty { get; set; } = null!;
public CounterpartyAdjustmentDirection Direction { get; set; }
public decimal Amount { get; set; }
public string? Notes { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
/// <summary>Проведено (учитывается в отчётах) или черновик.</summary>
public MoneyTransactionStatus Status { get; set; } = MoneyTransactionStatus.Draft;
}

View file

@ -0,0 +1,29 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Money;
/// <summary>Статья расходов (у МойСклада — <c>expenseItem</c>). Привязывается
/// к расходным документам: paymentout, cashout. Сид создаёт стартовый набор
/// (Закупка товара, Аренда, Зарплата, Коммунальные, Прочее) с
/// <see cref="IsSystem"/>=true — его переименовать можно, удалить нет.
///
/// Магазин может добавить свои (например, «Инкассация», «Хоз-товары») —
/// у них <see cref="IsSystem"/>=false, удаляются при условии «ни на одном
/// документе не используется» (валидатор в контроллере).</summary>
public class ExpenseItem : TenantEntity, IVersionedEntity
{
public uint Xmin { get; set; }
public string Name { get; set; } = null!;
/// <summary>Для UI: короткий код (например, "PURCHASE", "RENT"). Нужен
/// в основном сидеру + для сверки с 1С/МойСкладом при синхронизации.
/// Пользователь может оставить пустым.</summary>
public string? Code { get; set; }
public bool IsSystem { get; set; }
public bool IsActive { get; set; } = true;
public string? Notes { get; set; }
}

View file

@ -0,0 +1,81 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Money;
/// <summary>Что это за «место, где лежат деньги». У МойСклада наличные и
/// банковские счета — две разные сущности (<c>cashbox</c>-less для cash,
/// <c>organizationAccount</c> для безнала); у нас в простом режиме магазину
/// проще видеть единый справочник «Счета и кассы», поэтому различаем через
/// <see cref="MoneyAccountKind"/>:
/// <list type="bullet">
/// <item><b>Cash</b> — физическая касса магазина (деньги в ящике ККМ).
/// Привязана к <see cref="RetailPoint"/>: деньги смены этой точки
/// отражаются именно на этом счёте.</item>
/// <item><b>Safe</b> — сейф, обычно у директора/бухгалтера. Деньги попадают
/// сюда через инкассации «касса → сейф».</item>
/// <item><b>Bank</b> — расчётный счёт юрлица (Kaspi, Halyk и т.п.).
/// На один магазин допускается несколько банковских счетов.</item>
/// <item><b>Acquiring</b> — счёт эквайринга: куда банк зачисляет оплаты
/// картой с POS. Как правило отдельный от основного расчётного, с
/// задержкой перевода T+1 и своей комиссией.</item>
/// </list>
/// Начальный остаток <see cref="OpeningBalance"/> фиксируется на дату
/// <see cref="OpeningDate"/> и считается «данностью» — все проведённые
/// движения прибавляются/вычитаются от него. Инвариант:
/// <c>CurrentBalance = OpeningBalance + Σ MoneyTransactions.Signed</c>.
/// Проверяется в <c>MoneyReportsController</c>.</summary>
public enum MoneyAccountKind
{
Cash = 1,
Safe = 2,
Bank = 3,
Acquiring = 4,
}
public class MoneyAccount : TenantEntity, IVersionedEntity
{
public uint Xmin { get; set; }
/// <summary>Отображаемое имя: «Касса магазина Абая», «Kaspi основной»,
/// «Сейф директора». Уникальность — только в рамках организации
/// (разные магазины одной орги могут иметь «Касса»).</summary>
public string Name { get; set; } = null!;
public MoneyAccountKind Kind { get; set; }
/// <summary>Банк: только для <see cref="MoneyAccountKind.Bank"/> и
/// <see cref="MoneyAccountKind.Acquiring"/>. Для Cash/Safe — null.</summary>
public string? BankName { get; set; }
/// <summary>Номер счёта. Для банк./эквайринга — IBAN или просто номер.
/// Опционален (магазин может не хотеть вводить).</summary>
public string? AccountNumber { get; set; }
/// <summary>Привязка кассы к магазину. Для Bank/Safe обычно null (счета
/// юрлица, не магазина), для Cash — обязательна (нужна чтобы знать,
/// куда идёт выручка POS). Для Acquiring рекомендована, но не required.</summary>
public Guid? StoreId { get; set; }
public Store? Store { get; set; }
public Guid CurrencyId { get; set; }
public Currency Currency { get; set; } = null!;
/// <summary>Начальный остаток на дату старта учёта в системе. Задаётся
/// один раз при создании счёта; потом редактировать нельзя — иначе
/// расходится история (инвариант остатков будет врать). Если
/// действительно нужно переоткрыться — удалить все движения этого
/// счёта и пересоздать.</summary>
public decimal OpeningBalance { get; set; }
public DateTime OpeningDate { get; set; } = DateTime.UtcNow;
/// <summary>Если false — счёт виден только для чтения отчёта и не
/// предлагается в выпадающих списках новых документов. Удалять не
/// даём (есть движения), только архивация флажком.</summary>
public bool IsActive { get; set; } = true;
/// <summary>Удобный хинт для UI: по умолчанию выбирать именно этот
/// счёт при создании кассового/безнал. ордера. Один per
/// <see cref="Kind"/> в орге.</summary>
public bool IsDefault { get; set; }
}

View file

@ -0,0 +1,135 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
using foodmarket.Domain.Purchases;
namespace foodmarket.Domain.Money;
/// <summary>Тип финансового документа — объединяет МойСкладовские
/// <c>paymentin</c>, <c>paymentout</c>, <c>cashin</c>, <c>cashout</c>
/// в одну сущность <see cref="MoneyTransaction"/> с дискриминатором.
/// Переводы между своими счетами моделируем парой проводок
/// (TransferOut + TransferIn), соединённых <see cref="MoneyTransaction.LinkedTransactionId"/>.</summary>
public enum MoneyTransactionKind
{
/// <summary>Приход безнала на банк. счёт (аналог paymentin).</summary>
PaymentIn = 1,
/// <summary>Расход безнала с банк. счёта (аналог paymentout).</summary>
PaymentOut = 2,
/// <summary>Приход наличных в кассу/сейф (аналог cashin).</summary>
CashIn = 3,
/// <summary>Расход наличных из кассы/сейфа (аналог cashout).</summary>
CashOut = 4,
/// <summary>Расходная половина перевода между своими счетами
/// (с ParentId → приходная половина).</summary>
TransferOut = 5,
/// <summary>Приходная половина перевода.</summary>
TransferIn = 6,
}
public enum MoneyTransactionStatus
{
Draft = 0,
Posted = 1,
}
/// <summary>Единый финансовый документ: платёж/ордер/перевод. У каждого
/// документа есть одна сторона (<see cref="AccountId"/>) и знак:
/// <c>In</c>-варианты увеличивают баланс счёта, <c>Out</c> — уменьшают.
///
/// Привязка к первичным документам (приёмкам/возвратам поставщика/продажам/
/// возвратам покупателю) — через коллекцию <see cref="Links"/>, по образцу
/// МойСкладовских <c>operations[linkedSum]</c>. На одной транзакции может
/// быть N ссылок с суммами, разложенных по разным документам (частичная
/// оплата допустима). Σ Links.LinkedSum не должна превышать Amount, но
/// может быть меньше (кусок суммы «без закрытия документа» — аналог
/// MoySklad <c>noClosingDocs</c>).
///
/// Проведённые транзакции (<see cref="Status"/>=Posted) неизменяемы;
/// отмена = отдельный флаг <see cref="Status"/>=Draft + пересчёт
/// <c>Supply.PayedSum</c> в сторону уменьшения, в одной транзакции
/// Postgres (см. <c>MoneyService.Unpost</c>).</summary>
public class MoneyTransaction : TenantEntity, IVersionedEntity
{
public uint Xmin { get; set; }
/// <summary>Человекочитаемый номер, уникальный в орге. Генерим по шаблону
/// <c>Д-{year}-{seq6}</c> (Д как «деньги»), общий счётчик на все виды
/// MoneyTransaction — чтобы в списке «Платежи и ордера» документы шли
/// в порядке создания, а не по трём разным счётчикам.</summary>
public string Number { get; set; } = "";
public DateTime Date { get; set; } = DateTime.UtcNow;
public MoneyTransactionKind Kind { get; set; }
public MoneyTransactionStatus Status { get; set; } = MoneyTransactionStatus.Draft;
/// <summary>Счёт/касса, с которой это движение (приход — зачисление сюда,
/// расход — списание отсюда).</summary>
public Guid AccountId { get; set; }
public MoneyAccount Account { get; set; } = null!;
/// <summary>Контрагент: обязателен для PaymentIn/PaymentOut/CashIn/CashOut,
/// null для TransferIn/TransferOut (там контрагент — сами).</summary>
public Guid? CounterpartyId { get; set; }
public Counterparty? Counterparty { get; set; }
/// <summary>Статья расходов — только для PaymentOut/CashOut. У приходных
/// документов и переводов — null.</summary>
public Guid? ExpenseItemId { get; set; }
public ExpenseItem? ExpenseItem { get; set; }
public Guid CurrencyId { get; set; }
public Currency Currency { get; set; } = null!;
/// <summary>Сумма документа. Положительная (знак определяется Kind).</summary>
public decimal Amount { get; set; }
/// <summary>Назначение/основание платежа. У МойСклада обязательное
/// (`paymentPurpose`), но для простого магазина делаем необязательным —
/// кассир редко вписывает что-то кроме «За товар».</summary>
public string? Purpose { get; set; }
/// <summary>Общий комментарий документа.</summary>
public string? Notes { get; set; }
/// <summary>Для переводов — ссылка на парную половину (TransferOut↔TransferIn).
/// Проводятся всегда вместе, в одной БД-транзакции. Для всех остальных
/// Kind — null.</summary>
public Guid? LinkedTransactionId { get; set; }
public MoneyTransaction? LinkedTransaction { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
public ICollection<MoneyTransactionLink> Links { get; set; } = new List<MoneyTransactionLink>();
}
/// <summary>Один <see cref="MoneyTransaction"/> ↔ один первичный документ с
/// распределённой суммой. У MoySklad это <c>operations[].linkedSum</c>.
///
/// Для простого режима мы сейчас поддерживаем только привязку к
/// <see cref="Supply"/> (приёмка) и <see cref="SupplierReturn"/> (возврат
/// поставщику) — обе колонки nullable, ровно одна из них должна быть
/// заполнена; валидируется в <c>MoneyService.PostAsync</c> и
/// проверочным ограничением в БД. На Phase2 (расширенный режим) добавим
/// Demand/RetailSale/CustomerReturn — тогда появятся соответствующие
/// колонки, таблица расширится без миграции данных.</summary>
public class MoneyTransactionLink : TenantEntity
{
public Guid TransactionId { get; set; }
public MoneyTransaction Transaction { get; set; } = null!;
public Guid? SupplyId { get; set; }
public Supply? Supply { get; set; }
public Guid? SupplierReturnId { get; set; }
public SupplierReturn? SupplierReturn { get; set; }
public decimal LinkedSum { get; set; }
}

View file

@ -55,6 +55,19 @@ public class RolePermissions
public bool LoyaltyManage { get; set; }
public bool PromotionsManage { get; set; }
// Sprint 33 (модуль «Деньги»):
// MoneyView — видеть счета, движения, отчёт «Деньги», долги.
// Кассир — да (видит выручку своей смены); Кладовщик — только через SuppliesView,
// увидит оплаченность приёмки, но не остатки по счетам.
// MoneyEdit — создавать/редактировать платежи, ордера, переводы,
// счета, статьи расходов, корректировки взаиморасчётов.
// Admin-only по умолчанию.
// CashShiftOperate — открыть/закрыть смену, вносить/выплачивать деньги
// в своей смене. Кассир — да.
public bool MoneyView { get; set; }
public bool MoneyEdit { get; set; }
public bool CashShiftOperate { get; set; }
/// <summary>Полный набор всех true — для системной роли «Администратор».</summary>
public static RolePermissions All() => new()
{
@ -70,5 +83,6 @@ public static RolePermissions All() => new()
StoresManage = true, RetailPointsManage = true,
CashRegistersManage = true, IntegrationsManage = true,
LoyaltyManage = true, PromotionsManage = true,
MoneyView = true, MoneyEdit = true, CashShiftOperate = true,
};
}

View file

@ -39,6 +39,11 @@ public class SupplierReturn : TenantEntity
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
/// <summary>Сколько поставщик вернул нам денег по этому возврату
/// (аналог <see cref="Supply.PayedSum"/>). Пересчитывает
/// <c>MoneyService</c>; инвариант <c>0 ≤ PayedSum ≤ Total</c>.</summary>
public decimal PayedSum { get; set; }
public ICollection<SupplierReturnLine> Lines { get; set; } = new List<SupplierReturnLine>();
}

View file

@ -37,6 +37,14 @@ public class Supply : TenantEntity, IVersionedEntity
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
/// <summary>Сколько из <see cref="Total"/> оплачено — сумма всех
/// проведённых <c>MoneyTransactionLink.LinkedSum</c>, где
/// <c>SupplyId</c>=этой приёмки. Пересчитывается в <c>MoneyService</c>
/// при проведении/отмене платежа. Инвариант: <c>0 ≤ PayedSum ≤ Total</c>.
/// Статус оплаты получаем сравнением: PayedSum==0 — не оплачена,
/// 0&lt;PayedSum&lt;Total — частично, PayedSum&gt;=Total — оплачена.</summary>
public decimal PayedSum { get; set; }
public ICollection<SupplyLine> Lines { get; set; } = new List<SupplyLine>();
}

View file

@ -71,6 +71,14 @@ public class RetailSale : TenantEntity, IVersionedEntity
public decimal PaidCash { get; set; }
public decimal PaidCard { get; set; }
/// <summary>Смена, в рамках которой проведён чек. Обязателен для
/// POS-продаж на точке с живой кассой; <c>null</c> у исторических чеков
/// до внедрения модуля «Деньги» и у безсменных операций (bulk-импорт,
/// технический seed, тесты). Проверка на обязательность — в
/// <c>RetailSalesController.Post</c>: если <c>CashShift</c> на точке
/// не открыта, продажа отклоняется с кодом <c>cash-shift-required</c>.</summary>
public Guid? CashShiftId { get; set; }
public string? Notes { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }

View file

@ -0,0 +1,48 @@
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Infrastructure.Money;
/// <summary>Генераторы человекочитаемых номеров для документов модуля «Деньги».
/// Общий счётчик на все виды <c>MoneyTransaction</c> (чтобы в едином списке
/// «Платежи и ордера» документы шли по возрастанию); отдельный — для смен и
/// корректировок. Формат — префикс + год + 6-значный seq.
///
/// Реализация прочтением MAX номера под advisory-lock'ом (сериализация внутри
/// одной орги). Для prod с десятками тысяч документов стоит переработать на
/// отдельную таблицу-счётчик, но для простого режима и ожидаемой нагрузки
/// такой подход экономит таблицу и работает корректно под Serializable.</summary>
public static class MoneyNumbering
{
public static Task<string> NextMoneyTxNumberAsync(AppDbContext db, Guid orgId, int year, CancellationToken ct)
=> NextByPrefixAsync(db, "money_transactions", "Д", orgId, year, ct);
public static Task<string> NextShiftNumberAsync(AppDbContext db, Guid orgId, int year, CancellationToken ct)
=> NextByPrefixAsync(db, "cash_shifts", "С", orgId, year, ct);
public static Task<string> NextAdjustmentNumberAsync(AppDbContext db, Guid orgId, int year, CancellationToken ct)
=> NextByPrefixAsync(db, "counterparty_adjustments", "К", orgId, year, ct);
private static async Task<string> NextByPrefixAsync(AppDbContext db, string table, string prefix,
Guid orgId, int year, CancellationToken ct)
{
// Advisory-lock берём на пару (hash(orgId), hash(table + prefix)) чтобы
// concurrent-инсерты в разные таблицы одной орги не блокировали друг
// друга, но два потока в одну таблицу — сериализовались.
var key1 = unchecked((int)(orgId.GetHashCode()));
var key2 = (table + prefix).GetHashCode();
await db.Database.ExecuteSqlRawAsync(
"SELECT pg_advisory_xact_lock({0}, {1})", key1, key2);
var pattern = $"{prefix}-{year}-%";
// table/prefix захардкожены в вызывающих местах — безопасно.
var sql = $@"SELECT COALESCE(MAX(CAST(SPLIT_PART(""Number"", '-', 3) AS int)), 0) AS ""Value""
FROM public.{table}
WHERE ""OrganizationId"" = {{0}} AND ""Number"" LIKE {{1}}";
var maxSeq = await db.Database
.SqlQueryRaw<int>(sql, orgId, pattern)
.SingleAsync(ct);
var next = maxSeq + 1;
return $"{prefix}-{year}-{next:D6}";
}
}

View file

@ -0,0 +1,311 @@
using foodmarket.Application.Common.Tenancy;
using foodmarket.Application.Money;
using foodmarket.Domain.Money;
using foodmarket.Domain.Purchases;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace foodmarket.Infrastructure.Money;
/// <summary>Единая реализация <see cref="IMoneyService"/>. Внутри — Serializable
/// транзакции на всю операцию (post/unpost/open/close/drawer); при конкуренте
/// Postgres падает в 40001, миддлварь (<c>SerializationConflictMiddleware</c>)
/// преобразует в 409. Любые изменения <c>Supply.PayedSum</c> / <c>SupplierReturn.PayedSum</c>
/// идут только отсюда — чтобы инвариант «PayedSum ∈ [0,Total]» был централизованно
/// защищён.</summary>
public sealed class MoneyService : IMoneyService
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly ILogger<MoneyService> _log;
public MoneyService(AppDbContext db, ITenantContext tenant, ILogger<MoneyService> log)
{
_db = db;
_tenant = tenant;
_log = log;
}
public async Task<MoneyServiceResult> PostAsync(Guid transactionId, Guid currentUserId, CancellationToken ct)
{
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
var mt = await _db.MoneyTransactions
.Include(m => m.Account)
.Include(m => m.Links)
.ThenInclude(l => l.Supply)
.Include(m => m.Links)
.ThenInclude(l => l.SupplierReturn)
.FirstOrDefaultAsync(m => m.Id == transactionId, ct);
if (mt is null)
return MoneyServiceResult.Fail(MoneyServiceStatus.NotFound, "not-found", "Документ не найден.");
if (mt.Status == MoneyTransactionStatus.Posted)
return MoneyServiceResult.Fail(MoneyServiceStatus.AlreadyPosted, "already-posted", "Документ уже проведён.");
// Валидация валюты счёта и документа.
if (mt.Account.CurrencyId != mt.CurrencyId)
return MoneyServiceResult.Fail(MoneyServiceStatus.WrongCurrency, "wrong-currency",
"Валюта документа не совпадает с валютой счёта.");
if (!mt.Account.IsActive)
return MoneyServiceResult.Fail(MoneyServiceStatus.AccountInactive, "account-inactive",
"Счёт/касса архивированы — проводить движения по ним нельзя.");
if (mt.Amount <= 0)
return MoneyServiceResult.Fail(MoneyServiceStatus.ValidationFailed, "amount-must-be-positive",
"Сумма платежа должна быть больше нуля.");
// Сумма связей не может превышать сумму документа.
var linkedTotal = mt.Links.Sum(l => l.LinkedSum);
if (linkedTotal > mt.Amount + 0.0001m)
return MoneyServiceResult.Fail(MoneyServiceStatus.LinkedSumMismatch, "linked-sum-exceeds-amount",
$"Сумма разноски ({linkedTotal:0.##}) больше суммы документа ({mt.Amount:0.##}).");
// Пересчёт PayedSum на каждом целевом документе — защищён инвариантом.
foreach (var link in mt.Links)
{
if (link.SupplyId is Guid sId)
{
var supply = await _db.Supplies.FirstAsync(s => s.Id == sId, ct);
var newPayed = supply.PayedSum + link.LinkedSum;
if (newPayed > supply.Total + 0.0001m)
return MoneyServiceResult.Fail(MoneyServiceStatus.OverpaidDocument, "supply-overpaid",
$"Приёмка {supply.Number} была бы переплачена: {newPayed:0.##} > {supply.Total:0.##}.");
supply.PayedSum = newPayed;
supply.UpdatedAt = DateTime.UtcNow;
}
else if (link.SupplierReturnId is Guid srId)
{
var sr = await _db.SupplierReturns.FirstAsync(s => s.Id == srId, ct);
var newPayed = sr.PayedSum + link.LinkedSum;
if (newPayed > sr.Total + 0.0001m)
return MoneyServiceResult.Fail(MoneyServiceStatus.OverpaidDocument, "supplier-return-overpaid",
$"Возврат поставщику {sr.Number} был бы переплачен: {newPayed:0.##} > {sr.Total:0.##}.");
sr.PayedSum = newPayed;
sr.UpdatedAt = DateTime.UtcNow;
}
}
mt.Status = MoneyTransactionStatus.Posted;
mt.PostedAt = DateTime.UtcNow;
mt.PostedByUserId = currentUserId;
mt.UpdatedAt = DateTime.UtcNow;
// Для TransferOut атомарно проводим парный TransferIn
if (mt.LinkedTransactionId is Guid linkedId && mt.Kind == MoneyTransactionKind.TransferOut)
{
var pair = await _db.MoneyTransactions.FirstOrDefaultAsync(m => m.Id == linkedId, ct);
if (pair is not null && pair.Status == MoneyTransactionStatus.Draft)
{
pair.Status = MoneyTransactionStatus.Posted;
pair.PostedAt = mt.PostedAt;
pair.PostedByUserId = currentUserId;
pair.UpdatedAt = mt.UpdatedAt;
}
}
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
_log.LogInformation("MoneyTransaction {Number} posted by {UserId}", mt.Number, currentUserId);
return MoneyServiceResult.Ok();
}
public async Task<MoneyServiceResult> UnpostAsync(Guid transactionId, Guid currentUserId, CancellationToken ct)
{
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
var mt = await _db.MoneyTransactions
.Include(m => m.Links)
.FirstOrDefaultAsync(m => m.Id == transactionId, ct);
if (mt is null)
return MoneyServiceResult.Fail(MoneyServiceStatus.NotFound, "not-found", "Документ не найден.");
if (mt.Status == MoneyTransactionStatus.Draft)
return MoneyServiceResult.Fail(MoneyServiceStatus.AlreadyDraft, "already-draft", "Документ уже черновик.");
foreach (var link in mt.Links)
{
if (link.SupplyId is Guid sId)
{
var supply = await _db.Supplies.FirstAsync(s => s.Id == sId, ct);
supply.PayedSum = Math.Max(0, supply.PayedSum - link.LinkedSum);
supply.UpdatedAt = DateTime.UtcNow;
}
else if (link.SupplierReturnId is Guid srId)
{
var sr = await _db.SupplierReturns.FirstAsync(s => s.Id == srId, ct);
sr.PayedSum = Math.Max(0, sr.PayedSum - link.LinkedSum);
sr.UpdatedAt = DateTime.UtcNow;
}
}
mt.Status = MoneyTransactionStatus.Draft;
mt.PostedAt = null;
mt.PostedByUserId = null;
mt.UpdatedAt = DateTime.UtcNow;
if (mt.LinkedTransactionId is Guid linkedId && mt.Kind == MoneyTransactionKind.TransferOut)
{
var pair = await _db.MoneyTransactions.FirstOrDefaultAsync(m => m.Id == linkedId, ct);
if (pair is not null && pair.Status == MoneyTransactionStatus.Posted)
{
pair.Status = MoneyTransactionStatus.Draft;
pair.PostedAt = null;
pair.PostedByUserId = null;
pair.UpdatedAt = mt.UpdatedAt;
}
}
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
_log.LogInformation("MoneyTransaction {Number} unposted by {UserId}", mt.Number, currentUserId);
return MoneyServiceResult.Ok();
}
public async Task<CashShiftOpenResult> OpenShiftAsync(Guid retailPointId, Guid cashAccountId,
Guid? acquiringAccountId, decimal openingFloat, Guid cashierUserId, CancellationToken ct)
{
if (openingFloat < 0)
return new(MoneyServiceStatus.ValidationFailed, null, null,
"opening-float-negative", "Размен не может быть отрицательным.");
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
var existing = await _db.CashShifts.AnyAsync(
s => s.RetailPointId == retailPointId && s.Status == CashShiftStatus.Open, ct);
if (existing)
return new(MoneyServiceStatus.ShiftAlreadyOpen, null, null,
"shift-already-open", "На этой точке уже открыта смена.");
var cash = await _db.MoneyAccounts.FirstOrDefaultAsync(a => a.Id == cashAccountId, ct);
if (cash is null || cash.Kind != MoneyAccountKind.Cash)
return new(MoneyServiceStatus.ValidationFailed, null, null,
"cash-account-invalid", "Счёт для смены должен быть типа «Касса».");
if (!cash.IsActive)
return new(MoneyServiceStatus.AccountInactive, null, null,
"account-inactive", "Касса архивирована.");
if (acquiringAccountId is Guid aid)
{
var acq = await _db.MoneyAccounts.FirstOrDefaultAsync(a => a.Id == aid, ct);
if (acq is null || acq.Kind != MoneyAccountKind.Acquiring)
return new(MoneyServiceStatus.ValidationFailed, null, null,
"acquiring-account-invalid", "Счёт эквайринга должен быть типа «Эквайринг».");
if (!acq.IsActive)
return new(MoneyServiceStatus.AccountInactive, null, null,
"account-inactive", "Счёт эквайринга архивирован.");
}
var orgId = _tenant.OrganizationId ?? Guid.Empty;
var now = DateTime.UtcNow;
var shift = new CashShift
{
OrganizationId = orgId,
Number = await MoneyNumbering.NextShiftNumberAsync(_db, orgId, now.Year, ct),
Status = CashShiftStatus.Open,
RetailPointId = retailPointId,
CashAccountId = cashAccountId,
AcquiringAccountId = acquiringAccountId,
CashierUserId = cashierUserId,
OpenedAt = now,
OpeningFloat = openingFloat,
};
_db.CashShifts.Add(shift);
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
_log.LogInformation("CashShift {Number} opened at {RetailPointId} by {CashierUserId}",
shift.Number, retailPointId, cashierUserId);
return new(MoneyServiceStatus.Ok, shift.Id, shift.Number, null, null);
}
public async Task<CashShiftCloseResult> CloseShiftAsync(Guid shiftId, decimal actualCash,
Guid closedByUserId, string? notes, CancellationToken ct)
{
if (actualCash < 0)
return new(MoneyServiceStatus.ValidationFailed, 0, 0,
"actual-cash-negative", "Фактическая сумма наличных не может быть отрицательной.");
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
var shift = await _db.CashShifts
.Include(s => s.DrawerOperations)
.FirstOrDefaultAsync(s => s.Id == shiftId, ct);
if (shift is null)
return new(MoneyServiceStatus.NotFound, 0, 0, "not-found", "Смена не найдена.");
if (shift.Status == CashShiftStatus.Closed)
return new(MoneyServiceStatus.ShiftNotOpen, shift.ExpectedCash, shift.CashDifference,
"shift-already-closed", "Смена уже закрыта.");
// Снапшоты продаж/возвратов в периоде смены (используем Id'шник
// смены на RetailSale).
var sales = await _db.RetailSales
.Where(s => s.CashShiftId == shift.Id && s.Status == RetailSaleStatus.Posted)
.ToListAsync(ct);
var salesCash = sales.Where(s => !s.IsReturn).Sum(s => s.PaidCash);
var salesCard = sales.Where(s => !s.IsReturn).Sum(s => s.PaidCard);
var returnsCash = sales.Where(s => s.IsReturn).Sum(s => s.PaidCash);
var returnsCard = sales.Where(s => s.IsReturn).Sum(s => s.PaidCard);
var drawerIn = shift.DrawerOperations.Where(o => o.Direction == CashDrawerDirection.In).Sum(o => o.Amount);
var drawerOut = shift.DrawerOperations.Where(o => o.Direction == CashDrawerDirection.Out).Sum(o => o.Amount);
var expected = shift.OpeningFloat + salesCash - returnsCash + drawerIn - drawerOut;
shift.SalesCash = salesCash;
shift.SalesCard = salesCard;
shift.ReturnsCash = returnsCash;
shift.ReturnsCard = returnsCard;
shift.DrawerIn = drawerIn;
shift.DrawerOut = drawerOut;
shift.ExpectedCash = expected;
shift.ActualCash = actualCash;
shift.CashDifference = actualCash - expected;
shift.Status = CashShiftStatus.Closed;
shift.ClosedAt = DateTime.UtcNow;
shift.ClosedByUserId = closedByUserId;
shift.Notes = notes;
shift.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
_log.LogInformation("CashShift {Number} closed by {UserId}: expected={Expected} actual={Actual} diff={Diff}",
shift.Number, closedByUserId, expected, actualCash, shift.CashDifference);
return new(MoneyServiceStatus.Ok, expected, shift.CashDifference, null, null);
}
public async Task<CashDrawerOperationResult> AddDrawerOperationAsync(Guid shiftId,
CashDrawerDirection direction, decimal amount, string reason,
Guid byUserId, CancellationToken ct)
{
if (amount <= 0)
return new(MoneyServiceStatus.ValidationFailed, null,
"amount-must-be-positive", "Сумма должна быть больше нуля.");
if (string.IsNullOrWhiteSpace(reason))
return new(MoneyServiceStatus.ValidationFailed, null,
"reason-required", "Причина обязательна.");
var shift = await _db.CashShifts.FirstOrDefaultAsync(s => s.Id == shiftId, ct);
if (shift is null)
return new(MoneyServiceStatus.NotFound, null, "not-found", "Смена не найдена.");
if (shift.Status != CashShiftStatus.Open)
return new(MoneyServiceStatus.ShiftNotOpen, null,
"shift-not-open", "Смена закрыта — добавлять операции нельзя.");
var op = new CashDrawerOperation
{
OrganizationId = shift.OrganizationId,
CashShiftId = shift.Id,
Direction = direction,
Amount = amount,
Reason = reason.Trim(),
At = DateTime.UtcNow,
ByUserId = byUserId,
};
_db.CashDrawerOperations.Add(op);
await _db.SaveChangesAsync(ct);
return new(MoneyServiceStatus.Ok, op.Id, null, null);
}
}

View file

@ -2,6 +2,7 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Money;
using foodmarket.Domain.Purchases;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Identity;
@ -82,6 +83,15 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<UserPreset> UserPresets => Set<UserPreset>();
public DbSet<OrgExport> OrgExports => Set<OrgExport>();
// Sprint 33: модуль «Деньги» — простой режим.
public DbSet<MoneyAccount> MoneyAccounts => Set<MoneyAccount>();
public DbSet<ExpenseItem> ExpenseItems => Set<ExpenseItem>();
public DbSet<MoneyTransaction> MoneyTransactions => Set<MoneyTransaction>();
public DbSet<MoneyTransactionLink> MoneyTransactionLinks => Set<MoneyTransactionLink>();
public DbSet<CashShift> CashShifts => Set<CashShift>();
public DbSet<CashDrawerOperation> CashDrawerOperations => Set<CashDrawerOperation>();
public DbSet<CounterpartyAdjustment> CounterpartyAdjustments => Set<CounterpartyAdjustment>();
/// <summary>Если true — <see cref="OrgAuditInterceptor"/> не пишет audit-строки
/// для этого SaveChanges. Используется сидерами/миграциями, фоновыми
/// импортами (где tenant-context отсутствует) и при ручной чистке логов.</summary>
@ -200,6 +210,7 @@ protected override void OnModelCreating(ModelBuilder builder)
builder.ConfigurePurchases();
builder.ConfigureSales();
builder.ConfigureOrganizationsHr();
builder.ConfigureMoney();
// Apply multi-tenant query filter to every entity that implements ITenantEntity.
// IOptionalTenantEntity (системные справочники с nullable OrganizationId) —

View file

@ -0,0 +1,176 @@
using foodmarket.Domain.Money;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Infrastructure.Persistence.Configurations;
/// <summary>Схема таблиц модуля «Деньги». Названия колонок/таблиц
/// подчинены общему стилю (snake_case через ToTable + EF-camel для свойств);
/// суммовые — <c>decimal(18,4)</c>; концуррентный токен — Postgres <c>xmin</c>.</summary>
public static class MoneyConfigurations
{
public static void ConfigureMoney(this ModelBuilder b)
{
b.Entity<MoneyAccount>(e =>
{
e.ToTable("money_accounts");
e.UseXminAsConcurrencyToken();
e.Ignore(x => x.Xmin);
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.Property(x => x.BankName).HasMaxLength(200);
e.Property(x => x.AccountNumber).HasMaxLength(100);
e.Property(x => x.OpeningBalance).HasPrecision(18, 4);
e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.OrganizationId, x.Name });
e.HasIndex(x => new { x.OrganizationId, x.Kind, x.IsActive });
// IsDefault — на уровне контроллера проверяем «только один per Kind»:
// составной уникальный индекс с фильтром IsDefault=true.
e.HasIndex(x => new { x.OrganizationId, x.Kind })
.IsUnique()
.HasFilter("\"IsDefault\" = true");
});
b.Entity<ExpenseItem>(e =>
{
e.ToTable("expense_items");
e.UseXminAsConcurrencyToken();
e.Ignore(x => x.Xmin);
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.Property(x => x.Code).HasMaxLength(50);
e.Property(x => x.Notes).HasMaxLength(1000);
e.HasIndex(x => new { x.OrganizationId, x.Name }).IsUnique();
e.HasIndex(x => new { x.OrganizationId, x.Code })
.IsUnique()
.HasFilter("\"Code\" IS NOT NULL");
});
b.Entity<MoneyTransaction>(e =>
{
e.ToTable("money_transactions");
e.UseXminAsConcurrencyToken();
e.Ignore(x => x.Xmin);
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
e.Property(x => x.Amount).HasPrecision(18, 4);
e.Property(x => x.Purpose).HasMaxLength(500);
e.Property(x => x.Notes).HasMaxLength(1000);
e.HasOne(x => x.Account).WithMany().HasForeignKey(x => x.AccountId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Counterparty).WithMany().HasForeignKey(x => x.CounterpartyId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.ExpenseItem).WithMany().HasForeignKey(x => x.ExpenseItemId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.LinkedTransaction).WithMany()
.HasForeignKey(x => x.LinkedTransactionId).OnDelete(DeleteBehavior.Restrict);
e.HasMany(x => x.Links).WithOne(l => l.Transaction)
.HasForeignKey(l => l.TransactionId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique();
e.HasIndex(x => new { x.OrganizationId, x.Date });
e.HasIndex(x => new { x.OrganizationId, x.Kind, x.Status });
e.HasIndex(x => new { x.OrganizationId, x.AccountId, x.Date });
e.HasIndex(x => new { x.OrganizationId, x.CounterpartyId })
.HasFilter("\"CounterpartyId\" IS NOT NULL");
});
b.Entity<MoneyTransactionLink>(e =>
{
e.ToTable("money_transaction_links");
e.Property(x => x.LinkedSum).HasPrecision(18, 4);
e.HasOne(x => x.Supply).WithMany().HasForeignKey(x => x.SupplyId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.SupplierReturn).WithMany().HasForeignKey(x => x.SupplierReturnId).OnDelete(DeleteBehavior.Restrict);
// CHECK: ровно одна целевая ссылка должна быть заполнена.
e.ToTable(t => t.HasCheckConstraint(
"ck_money_link_single_target",
"((\"SupplyId\" IS NOT NULL)::int + (\"SupplierReturnId\" IS NOT NULL)::int) = 1"));
e.HasIndex(x => new { x.OrganizationId, x.SupplyId })
.HasFilter("\"SupplyId\" IS NOT NULL");
e.HasIndex(x => new { x.OrganizationId, x.SupplierReturnId })
.HasFilter("\"SupplierReturnId\" IS NOT NULL");
e.HasIndex(x => x.TransactionId);
});
b.Entity<CashShift>(e =>
{
e.ToTable("cash_shifts");
e.UseXminAsConcurrencyToken();
e.Ignore(x => x.Xmin);
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
e.Property(x => x.OpeningFloat).HasPrecision(18, 4);
e.Property(x => x.SalesCash).HasPrecision(18, 4);
e.Property(x => x.SalesCard).HasPrecision(18, 4);
e.Property(x => x.ReturnsCash).HasPrecision(18, 4);
e.Property(x => x.ReturnsCard).HasPrecision(18, 4);
e.Property(x => x.DrawerIn).HasPrecision(18, 4);
e.Property(x => x.DrawerOut).HasPrecision(18, 4);
e.Property(x => x.ExpectedCash).HasPrecision(18, 4);
e.Property(x => x.ActualCash).HasPrecision(18, 4);
e.Property(x => x.CashDifference).HasPrecision(18, 4);
e.Property(x => x.Notes).HasMaxLength(1000);
e.HasOne(x => x.RetailPoint).WithMany().HasForeignKey(x => x.RetailPointId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.CashAccount).WithMany().HasForeignKey(x => x.CashAccountId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.AcquiringAccount).WithMany().HasForeignKey(x => x.AcquiringAccountId).OnDelete(DeleteBehavior.Restrict);
e.HasMany(x => x.DrawerOperations).WithOne(d => d.CashShift)
.HasForeignKey(d => d.CashShiftId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique();
e.HasIndex(x => new { x.OrganizationId, x.OpenedAt });
e.HasIndex(x => new { x.OrganizationId, x.RetailPointId, x.Status });
// Только одна открытая смена per RetailPoint.
e.HasIndex(x => new { x.OrganizationId, x.RetailPointId })
.IsUnique()
.HasFilter("\"Status\" = 1");
});
b.Entity<CashDrawerOperation>(e =>
{
e.ToTable("cash_drawer_operations");
e.UseXminAsConcurrencyToken();
e.Ignore(x => x.Xmin);
e.Property(x => x.Amount).HasPrecision(18, 4);
e.Property(x => x.Reason).HasMaxLength(500).IsRequired();
e.HasIndex(x => new { x.OrganizationId, x.CashShiftId });
e.HasIndex(x => new { x.OrganizationId, x.At });
});
b.Entity<CounterpartyAdjustment>(e =>
{
e.ToTable("counterparty_adjustments");
e.UseXminAsConcurrencyToken();
e.Ignore(x => x.Xmin);
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
e.Property(x => x.Amount).HasPrecision(18, 4);
e.Property(x => x.Notes).HasMaxLength(1000);
e.HasOne(x => x.Counterparty).WithMany().HasForeignKey(x => x.CounterpartyId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique();
e.HasIndex(x => new { x.OrganizationId, x.Date });
e.HasIndex(x => new { x.OrganizationId, x.CounterpartyId, x.Status });
});
// Расширения существующих таблиц:
b.Entity<foodmarket.Domain.Purchases.Supply>(e =>
{
e.Property(x => x.PayedSum).HasPrecision(18, 4);
e.HasIndex(x => new { x.OrganizationId, x.SupplierId, x.Status, x.PayedSum });
});
b.Entity<foodmarket.Domain.Purchases.SupplierReturn>(e =>
{
e.Property(x => x.PayedSum).HasPrecision(18, 4);
});
b.Entity<foodmarket.Domain.Sales.RetailSale>(e =>
{
e.HasIndex(x => new { x.OrganizationId, x.CashShiftId })
.HasFilter("\"CashShiftId\" IS NOT NULL");
});
}
}

View file

@ -0,0 +1,233 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase33a — модуль «Деньги» для простого режима.
///
/// Создаёт все таблицы модуля (money_accounts, expense_items,
/// money_transactions, money_transaction_links, cash_shifts,
/// cash_drawer_operations, counterparty_adjustments) и расширяет:
///
/// <list type="bullet">
/// <item>supplies — колонка <c>PayedSum</c> (NUMERIC(18,4) DEFAULT 0),
/// с индексом по (OrganizationId, SupplierId, Status, PayedSum).</item>
/// <item>supplier_returns — колонка <c>PayedSum</c>.</item>
/// <item>retail_sales — колонка <c>CashShiftId</c> (nullable uuid)
/// с условным индексом WHERE CashShiftId IS NOT NULL.</item>
/// </list>
///
/// Идемпотентно (IF NOT EXISTS везде); backfilling <c>PayedSum=0</c>
/// корректен, т.к. до этой фазы ни одна приёмка не могла быть оплачена
/// через модуль Денег.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20261007100000_Phase33a_Money")]
public partial class Phase33a_Money : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql(@"
-- ─── Счета и кассы ────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.money_accounts (
""Id"" uuid PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""Name"" varchar(200) NOT NULL,
""Kind"" integer NOT NULL,
""BankName"" varchar(200),
""AccountNumber"" varchar(100),
""StoreId"" uuid,
""CurrencyId"" uuid NOT NULL,
""OpeningBalance"" numeric(18,4) NOT NULL DEFAULT 0,
""OpeningDate"" timestamp with time zone NOT NULL,
""IsActive"" boolean NOT NULL DEFAULT true,
""IsDefault"" boolean NOT NULL DEFAULT false,
""CreatedAt"" timestamp with time zone NOT NULL,
""UpdatedAt"" timestamp with time zone,
CONSTRAINT ""FK_money_accounts_stores_StoreId"" FOREIGN KEY (""StoreId"") REFERENCES public.stores(""Id"") ON DELETE RESTRICT,
CONSTRAINT ""FK_money_accounts_currencies_CurrencyId"" FOREIGN KEY (""CurrencyId"") REFERENCES public.currencies(""Id"") ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS ""IX_money_accounts_OrganizationId_Name"" ON public.money_accounts (""OrganizationId"", ""Name"");
CREATE INDEX IF NOT EXISTS ""IX_money_accounts_OrganizationId_Kind_IsActive"" ON public.money_accounts (""OrganizationId"", ""Kind"", ""IsActive"");
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_money_accounts_OrganizationId_Kind_default""
ON public.money_accounts (""OrganizationId"", ""Kind"") WHERE ""IsDefault"" = true;
-- ─── Статьи расходов ──────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.expense_items (
""Id"" uuid PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""Name"" varchar(200) NOT NULL,
""Code"" varchar(50),
""IsSystem"" boolean NOT NULL DEFAULT false,
""IsActive"" boolean NOT NULL DEFAULT true,
""Notes"" varchar(1000),
""CreatedAt"" timestamp with time zone NOT NULL,
""UpdatedAt"" timestamp with time zone
);
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_expense_items_OrganizationId_Name"" ON public.expense_items (""OrganizationId"", ""Name"");
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_expense_items_OrganizationId_Code""
ON public.expense_items (""OrganizationId"", ""Code"") WHERE ""Code"" IS NOT NULL;
-- ─── Денежные документы ───────────────────────────────────
CREATE TABLE IF NOT EXISTS public.money_transactions (
""Id"" uuid PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""Number"" varchar(50) NOT NULL,
""Date"" timestamp with time zone NOT NULL,
""Kind"" integer NOT NULL,
""Status"" integer NOT NULL,
""AccountId"" uuid NOT NULL,
""CounterpartyId"" uuid,
""ExpenseItemId"" uuid,
""CurrencyId"" uuid NOT NULL,
""Amount"" numeric(18,4) NOT NULL,
""Purpose"" varchar(500),
""Notes"" varchar(1000),
""LinkedTransactionId"" uuid,
""PostedAt"" timestamp with time zone,
""PostedByUserId"" uuid,
""CreatedAt"" timestamp with time zone NOT NULL,
""UpdatedAt"" timestamp with time zone,
CONSTRAINT ""FK_money_transactions_accounts"" FOREIGN KEY (""AccountId"") REFERENCES public.money_accounts(""Id"") ON DELETE RESTRICT,
CONSTRAINT ""FK_money_transactions_counterparty"" FOREIGN KEY (""CounterpartyId"") REFERENCES public.counterparties(""Id"") ON DELETE RESTRICT,
CONSTRAINT ""FK_money_transactions_expense_item"" FOREIGN KEY (""ExpenseItemId"") REFERENCES public.expense_items(""Id"") ON DELETE RESTRICT,
CONSTRAINT ""FK_money_transactions_currency"" FOREIGN KEY (""CurrencyId"") REFERENCES public.currencies(""Id"") ON DELETE RESTRICT,
CONSTRAINT ""FK_money_transactions_linked"" FOREIGN KEY (""LinkedTransactionId"") REFERENCES public.money_transactions(""Id"") ON DELETE RESTRICT
);
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_money_transactions_OrganizationId_Number"" ON public.money_transactions (""OrganizationId"", ""Number"");
CREATE INDEX IF NOT EXISTS ""IX_money_transactions_OrganizationId_Date"" ON public.money_transactions (""OrganizationId"", ""Date"");
CREATE INDEX IF NOT EXISTS ""IX_money_transactions_OrganizationId_Kind_Status"" ON public.money_transactions (""OrganizationId"", ""Kind"", ""Status"");
CREATE INDEX IF NOT EXISTS ""IX_money_transactions_OrganizationId_AccountId_Date"" ON public.money_transactions (""OrganizationId"", ""AccountId"", ""Date"");
CREATE INDEX IF NOT EXISTS ""IX_money_transactions_OrganizationId_CounterpartyId""
ON public.money_transactions (""OrganizationId"", ""CounterpartyId"") WHERE ""CounterpartyId"" IS NOT NULL;
CREATE TABLE IF NOT EXISTS public.money_transaction_links (
""Id"" uuid PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""TransactionId"" uuid NOT NULL,
""SupplyId"" uuid,
""SupplierReturnId"" uuid,
""LinkedSum"" numeric(18,4) NOT NULL,
""CreatedAt"" timestamp with time zone NOT NULL,
""UpdatedAt"" timestamp with time zone,
CONSTRAINT ""FK_money_links_transaction"" FOREIGN KEY (""TransactionId"") REFERENCES public.money_transactions(""Id"") ON DELETE CASCADE,
CONSTRAINT ""FK_money_links_supply"" FOREIGN KEY (""SupplyId"") REFERENCES public.supplies(""Id"") ON DELETE RESTRICT,
CONSTRAINT ""FK_money_links_supplier_return"" FOREIGN KEY (""SupplierReturnId"") REFERENCES public.supplier_returns(""Id"") ON DELETE RESTRICT,
CONSTRAINT ""ck_money_link_single_target"" CHECK (
((""SupplyId"" IS NOT NULL)::int + (""SupplierReturnId"" IS NOT NULL)::int) = 1
)
);
CREATE INDEX IF NOT EXISTS ""IX_money_links_TransactionId"" ON public.money_transaction_links (""TransactionId"");
CREATE INDEX IF NOT EXISTS ""IX_money_links_OrganizationId_SupplyId""
ON public.money_transaction_links (""OrganizationId"", ""SupplyId"") WHERE ""SupplyId"" IS NOT NULL;
CREATE INDEX IF NOT EXISTS ""IX_money_links_OrganizationId_SupplierReturnId""
ON public.money_transaction_links (""OrganizationId"", ""SupplierReturnId"") WHERE ""SupplierReturnId"" IS NOT NULL;
-- ─── Смены кассы ──────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.cash_shifts (
""Id"" uuid PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""Number"" varchar(50) NOT NULL,
""Status"" integer NOT NULL,
""RetailPointId"" uuid NOT NULL,
""CashAccountId"" uuid NOT NULL,
""AcquiringAccountId"" uuid,
""CashierUserId"" uuid NOT NULL,
""OpenedAt"" timestamp with time zone NOT NULL,
""ClosedAt"" timestamp with time zone,
""ClosedByUserId"" uuid,
""OpeningFloat"" numeric(18,4) NOT NULL DEFAULT 0,
""SalesCash"" numeric(18,4) NOT NULL DEFAULT 0,
""SalesCard"" numeric(18,4) NOT NULL DEFAULT 0,
""ReturnsCash"" numeric(18,4) NOT NULL DEFAULT 0,
""ReturnsCard"" numeric(18,4) NOT NULL DEFAULT 0,
""DrawerIn"" numeric(18,4) NOT NULL DEFAULT 0,
""DrawerOut"" numeric(18,4) NOT NULL DEFAULT 0,
""ExpectedCash"" numeric(18,4) NOT NULL DEFAULT 0,
""ActualCash"" numeric(18,4),
""CashDifference"" numeric(18,4) NOT NULL DEFAULT 0,
""Notes"" varchar(1000),
""CreatedAt"" timestamp with time zone NOT NULL,
""UpdatedAt"" timestamp with time zone,
CONSTRAINT ""FK_cash_shifts_retail_point"" FOREIGN KEY (""RetailPointId"") REFERENCES public.retail_points(""Id"") ON DELETE RESTRICT,
CONSTRAINT ""FK_cash_shifts_cash_account"" FOREIGN KEY (""CashAccountId"") REFERENCES public.money_accounts(""Id"") ON DELETE RESTRICT,
CONSTRAINT ""FK_cash_shifts_acquiring_account"" FOREIGN KEY (""AcquiringAccountId"") REFERENCES public.money_accounts(""Id"") ON DELETE RESTRICT
);
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_cash_shifts_OrganizationId_Number"" ON public.cash_shifts (""OrganizationId"", ""Number"");
CREATE INDEX IF NOT EXISTS ""IX_cash_shifts_OrganizationId_OpenedAt"" ON public.cash_shifts (""OrganizationId"", ""OpenedAt"");
CREATE INDEX IF NOT EXISTS ""IX_cash_shifts_OrganizationId_RetailPointId_Status"" ON public.cash_shifts (""OrganizationId"", ""RetailPointId"", ""Status"");
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_cash_shifts_single_open_per_point""
ON public.cash_shifts (""OrganizationId"", ""RetailPointId"") WHERE ""Status"" = 1;
CREATE TABLE IF NOT EXISTS public.cash_drawer_operations (
""Id"" uuid PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""CashShiftId"" uuid NOT NULL,
""Direction"" integer NOT NULL,
""Amount"" numeric(18,4) NOT NULL,
""Reason"" varchar(500) NOT NULL,
""At"" timestamp with time zone NOT NULL,
""ByUserId"" uuid NOT NULL,
""CreatedAt"" timestamp with time zone NOT NULL,
""UpdatedAt"" timestamp with time zone,
CONSTRAINT ""FK_drawer_ops_cash_shift"" FOREIGN KEY (""CashShiftId"") REFERENCES public.cash_shifts(""Id"") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ""IX_drawer_ops_OrganizationId_CashShiftId"" ON public.cash_drawer_operations (""OrganizationId"", ""CashShiftId"");
CREATE INDEX IF NOT EXISTS ""IX_drawer_ops_OrganizationId_At"" ON public.cash_drawer_operations (""OrganizationId"", ""At"");
-- ─── Корректировки взаиморасчётов ─────────────────────────
CREATE TABLE IF NOT EXISTS public.counterparty_adjustments (
""Id"" uuid PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""Number"" varchar(50) NOT NULL,
""Date"" timestamp with time zone NOT NULL,
""CounterpartyId"" uuid NOT NULL,
""Direction"" integer NOT NULL,
""Amount"" numeric(18,4) NOT NULL,
""Notes"" varchar(1000),
""PostedAt"" timestamp with time zone,
""PostedByUserId"" uuid,
""Status"" integer NOT NULL DEFAULT 0,
""CreatedAt"" timestamp with time zone NOT NULL,
""UpdatedAt"" timestamp with time zone,
CONSTRAINT ""FK_cp_adj_counterparty"" FOREIGN KEY (""CounterpartyId"") REFERENCES public.counterparties(""Id"") ON DELETE RESTRICT
);
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_cp_adj_OrganizationId_Number"" ON public.counterparty_adjustments (""OrganizationId"", ""Number"");
CREATE INDEX IF NOT EXISTS ""IX_cp_adj_OrganizationId_Date"" ON public.counterparty_adjustments (""OrganizationId"", ""Date"");
CREATE INDEX IF NOT EXISTS ""IX_cp_adj_OrganizationId_CounterpartyId_Status"" ON public.counterparty_adjustments (""OrganizationId"", ""CounterpartyId"", ""Status"");
-- ─── Расширение Supply/SupplierReturn/RetailSale ──────────
ALTER TABLE public.supplies
ADD COLUMN IF NOT EXISTS ""PayedSum"" numeric(18,4) NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS ""IX_supplies_OrganizationId_SupplierId_Status_PayedSum""
ON public.supplies (""OrganizationId"", ""SupplierId"", ""Status"", ""PayedSum"");
ALTER TABLE public.supplier_returns
ADD COLUMN IF NOT EXISTS ""PayedSum"" numeric(18,4) NOT NULL DEFAULT 0;
ALTER TABLE public.retail_sales
ADD COLUMN IF NOT EXISTS ""CashShiftId"" uuid;
CREATE INDEX IF NOT EXISTS ""IX_retail_sales_OrganizationId_CashShiftId""
ON public.retail_sales (""OrganizationId"", ""CashShiftId"") WHERE ""CashShiftId"" IS NOT NULL;
");
}
protected override void Down(MigrationBuilder b)
{
b.Sql(@"
ALTER TABLE public.retail_sales DROP COLUMN IF EXISTS ""CashShiftId"";
ALTER TABLE public.supplier_returns DROP COLUMN IF EXISTS ""PayedSum"";
ALTER TABLE public.supplies DROP COLUMN IF EXISTS ""PayedSum"";
DROP TABLE IF EXISTS public.cash_drawer_operations;
DROP TABLE IF EXISTS public.cash_shifts;
DROP TABLE IF EXISTS public.counterparty_adjustments;
DROP TABLE IF EXISTS public.money_transaction_links;
DROP TABLE IF EXISTS public.money_transactions;
DROP TABLE IF EXISTS public.expense_items;
DROP TABLE IF EXISTS public.money_accounts;
");
}
}
}