diff --git a/docs/money-module-progress.md b/docs/money-module-progress.md
new file mode 100644
index 0000000..62ac3d2
--- /dev/null
+++ b/docs/money-module-progress.md
@@ -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-рендер нужен).
diff --git a/docs/money-moysklad-reference.md b/docs/money-moysklad-reference.md
new file mode 100644
index 0000000..ab86837
--- /dev/null
+++ b/docs/money-moysklad-reference.md
@@ -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` — не нужно, у магазина нет
+ документооборота с банком.
+
+Эти поля не создаём в простом режиме, но резервируем места в именованиях
+колонок на будущее.
diff --git a/src/food-market.api/Controllers/Money/CashShiftsController.cs b/src/food-market.api/Controllers/Money/CashShiftsController.cs
new file mode 100644
index 0000000..6dec6cd
--- /dev/null
+++ b/src/food-market.api/Controllers/Money/CashShiftsController.cs
@@ -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;
+
+/// Смены кассы — открыть/закрыть + внесения/выплаты. Z-отчёт
+/// получаем через GET /{id}/report, который возвращает всё в
+/// печатаемом виде (суммы продаж/возвратов, нал/карта, разница).
+///
+/// Если текущий пользователь — не Admin, он видит только свои смены;
+/// если Admin — все. Это закреплено в контроллере, а не в query-filter'е
+/// (нужна отдельная логика «моя смена» для кассира).
+[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>> 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(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 { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
+ }
+
+ [HttpGet("{id:guid}"), RequiresPermission("MoneyView")]
+ public async Task> 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> 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> 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> 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> 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 });
+ }
+
+ /// Z-отчёт: все цифры смены + внесения/выплаты для печати.
+ [HttpGet("{id:guid}/report"), RequiresPermission("MoneyView")]
+ public async Task> 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;
+ }
+}
diff --git a/src/food-market.api/Controllers/Money/CounterpartyAdjustmentsController.cs b/src/food-market.api/Controllers/Money/CounterpartyAdjustmentsController.cs
new file mode 100644
index 0000000..f05fade
--- /dev/null
+++ b/src/food-market.api/Controllers/Money/CounterpartyAdjustmentsController.cs
@@ -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;
+
+/// Корректировки взаиморасчётов — стартовый долг или ручное исправление.
+/// Проведение влияет на отчёт долгов (SupplierDebtsController), на баланс счетов — нет.
+[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>> 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(x, "xmin")))
+ .ToListAsync(ct);
+ return new PagedResult { Items = rows, Total = total, Page = req.Page, PageSize = req.Take };
+ }
+
+ [HttpPost, RequiresPermission("MoneyEdit")]
+ public async Task> 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> 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> 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 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 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 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 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(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;
+ }
+}
diff --git a/src/food-market.api/Controllers/Money/ExpenseItemsController.cs b/src/food-market.api/Controllers/Money/ExpenseItemsController.cs
new file mode 100644
index 0000000..202fe5e
--- /dev/null
+++ b/src/food-market.api/Controllers/Money/ExpenseItemsController.cs
@@ -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;
+
+/// Справочник статей расходов. Системные (IsSystem=true) —
+/// переименовать можно, удалить нет. Остальные — удалить можно, если ни
+/// один не ссылается.
+[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>> 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(x, "xmin")))
+ .ToListAsync(ct);
+ return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
+ }
+
+ [HttpPost, RequiresPermission("MoneyEdit")]
+ public async Task> 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> 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> 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 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);
+}
diff --git a/src/food-market.api/Controllers/Money/MoneyAccountsController.cs b/src/food-market.api/Controllers/Money/MoneyAccountsController.cs
new file mode 100644
index 0000000..0f34612
--- /dev/null
+++ b/src/food-market.api/Controllers/Money/MoneyAccountsController.cs
@@ -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;
+
+/// Справочник «Счета и кассы» — CRUD. На любой счёт, у которого
+/// есть хотя бы одно движение (money_transactions.AccountId) или
+/// открытая смена (cash_shifts.CashAccountId), DELETE запрещён —
+/// возвращаем 409 c кодом account-in-use; просим архивировать
+/// (IsActive=false).
+[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>> 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(x.a, "xmin"));
+ }).ToList();
+
+ return new PagedResult
+ {
+ Items = rows,
+ Total = total,
+ Page = req.Page,
+ PageSize = req.Take,
+ };
+ }
+
+ [HttpPost, RequiresPermission("MoneyEdit")]
+ public async Task> 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> 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> 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)NotFound();
+ }
+
+ [HttpDelete("{id:guid}"), RequiresPermission("MoneyEdit")]
+ public async Task 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 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(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);
+ }
+}
diff --git a/src/food-market.api/Controllers/Money/MoneyReportsController.cs b/src/food-market.api/Controllers/Money/MoneyReportsController.cs
new file mode 100644
index 0000000..f1cefe6
--- /dev/null
+++ b/src/food-market.api/Controllers/Money/MoneyReportsController.cs
@@ -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;
+
+/// Отчёты модуля «Деньги»: остатки по счетам, движение за период,
+/// долги поставщикам. По образцу МойСклад /report/money/plotseries и
+/// /report/counterparty (упрощённо).
+[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 Accounts, decimal TotalByCurrencySum);
+
+ [HttpGet("balances"), RequiresPermission("MoneyView")]
+ public async Task> 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 Series);
+
+ /// Движение денежных средств за период (аналог
+ /// /report/money/plotseries). Интервал — day/month.
+ [HttpGet("movement"), RequiresPermission("MoneyView")]
+ public async Task> 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 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 Rows, decimal TotalDebt);
+
+ /// Долги поставщикам: Σ приёмок − Σ возвратов − Σ оплат + Σ корректировок.
+ /// Расчёт в C#, т.к. нужны джойны + aggregate'ы, которые EF транслирует неоптимально.
+ [HttpGet("supplier-debts"), RequiresPermission("MoneyView")]
+ public async Task> 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();
+ 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));
+ }
+}
diff --git a/src/food-market.api/Controllers/Money/MoneyTransactionsController.cs b/src/food-market.api/Controllers/Money/MoneyTransactionsController.cs
new file mode 100644
index 0000000..a633101
--- /dev/null
+++ b/src/food-market.api/Controllers/Money/MoneyTransactionsController.cs
@@ -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;
+
+/// Платежи и ордера (единый reпозит на все виды
+/// кроме «ящиковых» внутри смены — те через смену).
+///
+/// Создание — POST /, где Kind определяет тип документа.
+/// Переводы между своими счетами делаются одним POST /transfers,
+/// внутри атомарно создаётся пара TransferOut + TransferIn (с
+/// LinkedTransactionId в обе стороны); проведение/отмена — тоже парная,
+/// см. .
+///
+/// Проведение / отмена — POST /{id}/post, POST /{id}/unpost.
+/// Привязка к приёмке/возврату — через поле Links, разноска суммы
+/// по правилу «Σ LinkedSum ≤ Amount» (валидация в ).
+[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 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? Links = null,
+ uint? Xmin = null);
+
+ [HttpGet, RequiresPermission("MoneyView")]
+ public async Task>> 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(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 { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
+ }
+
+ [HttpGet("{id:guid}"), RequiresPermission("MoneyView")]
+ public async Task> Get(Guid id, CancellationToken ct)
+ {
+ var row = await ReadOneAsync(id, ct);
+ return row is null ? NotFound() : row;
+ }
+
+ [HttpPost, RequiresPermission("MoneyEdit")]
+ public async Task> 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())
+ {
+ // 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> 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())
+ {
+ 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 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 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 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> 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 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(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);
+ }
+}
diff --git a/src/food-market.api/Controllers/Purchases/SuppliesController.cs b/src/food-market.api/Controllers/Purchases/SuppliesController.cs
index 91e085f..e58b89d 100644
--- a/src/food-market.api/Controllers/Purchases/SuppliesController.cs
+++ b/src/food-market.api/Controllers/Purchases/SuppliesController.cs
@@ -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);
+
+ /// Статус оплаты приёмки — вычисляемая строка:
+ /// "unpaid" (0), "partial" (0 < payed < total), "paid" (payed ≥ total).
+ /// Фронт показывает цветной бейдж. Расчёт здесь — простейший порог.
+ 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 Lines);
+ IReadOnlyList 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 { 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));
}
}
diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs
index 985f89c..fa61cbf 100644
--- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs
+++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs
@@ -569,6 +569,28 @@ private async Task 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);
diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs
index 65265a7..296acaf 100644
--- a/src/food-market.api/Program.cs
+++ b/src/food-market.api/Program.cs
@@ -436,6 +436,10 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
// Inventory
builder.Services.AddScoped();
+ // Sprint 33: модуль «Деньги»
+ builder.Services.AddScoped();
+
// Hangfire — фоновые джобы и UI-дашборд. Хранилище — наш Postgres
// (тот же ConnectionString:Default). Запускаем фактический сервер только
// когда приложение действительно работает (не в тестах): чтобы тестовые
diff --git a/src/food-market.api/Seed/DevDataSeeder.cs b/src/food-market.api/Seed/DevDataSeeder.cs
index 21b4f15..a2055af 100644
--- a/src/food-market.api/Seed/DevDataSeeder.cs
+++ b/src/food-market.api/Seed/DevDataSeeder.cs
@@ -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);
+ }
+
+ /// Системные статьи расходов для новой организации — пять по
+ /// умолчанию (из ТЗ модуля «Деньги»). Их можно переименовать, но
+ /// нельзя удалить (=true), чтобы
+ /// старые документы не остались с «висящим» FK.
+ 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);
}
/// Канонический набор 5 глобальных единиц измерения (Phase5c).
diff --git a/src/food-market.application/Money/IMoneyService.cs b/src/food-market.application/Money/IMoneyService.cs
new file mode 100644
index 0000000..5243f54
--- /dev/null
+++ b/src/food-market.application/Money/IMoneyService.cs
@@ -0,0 +1,69 @@
+using foodmarket.Domain.Money;
+
+namespace foodmarket.Application.Money;
+
+/// Единый API модуля «Деньги». Контроллеры дёргают только эти
+/// методы — внутри сервиса сидит вся бизнес-логика: пересчёт
+/// Supply.PayedSum, парная проводка переводов, расчёт expected
+/// cash для смены, применение статусов. Все методы Serializable-safe
+/// (транзакция внутри — одна на операцию).
+public interface IMoneyService
+{
+ /// Провести готовый документ: смена Status Draft→Posted,
+ /// пересчёт PayedSum на связанных документах. Пустой список
+ /// Links допустим (аналог noClosingDocs=true). Для TransferOut
+ /// — атомарно создаёт/проводит парный TransferIn.
+ Task PostAsync(Guid transactionId, Guid currentUserId, CancellationToken ct);
+
+ /// Отменить проведение. Пересчёт PayedSum в сторону уменьшения.
+ /// После Unpost документ можно удалить или снова отредактировать и
+ /// провести заново.
+ Task UnpostAsync(Guid transactionId, Guid currentUserId, CancellationToken ct);
+
+ /// Открыть смену на точке. Падает 409 если на RetailPoint
+ /// уже есть открытая смена.
+ Task OpenShiftAsync(Guid retailPointId, Guid cashAccountId,
+ Guid? acquiringAccountId, decimal openingFloat, Guid cashierUserId, CancellationToken ct);
+
+ /// Закрыть смену: вычислить snapshot'ы продаж/возвратов,
+ /// expected cash, разницу. Запрещено если уже закрыта.
+ Task CloseShiftAsync(Guid shiftId, decimal actualCash,
+ Guid closedByUserId, string? notes, CancellationToken ct);
+
+ /// Добавить внесение/выплату внутри открытой смены.
+ Task 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);
diff --git a/src/food-market.domain/Money/CashShift.cs b/src/food-market.domain/Money/CashShift.cs
new file mode 100644
index 0000000..caa9ce6
--- /dev/null
+++ b/src/food-market.domain/Money/CashShift.cs
@@ -0,0 +1,118 @@
+using foodmarket.Domain.Catalog;
+using foodmarket.Domain.Common;
+
+namespace foodmarket.Domain.Money;
+
+/// Статус кассовой смены. От МойСклада берём идею, но упрощённо:
+/// Open — ящик открыт, можно продавать; Closed — закрыт кассиром с указанием
+/// фактических денег в ящике, пересчитан expected vs actual. Z-отчёт при
+/// закрытии — просмотр и печать по этой записи.
+public enum CashShiftStatus
+{
+ Open = 1,
+ Closed = 2,
+}
+
+/// Розничная смена кассы — аналог МойСкладовского
+/// retailshift. Одна открытая смена per :
+/// повторный open на той же точке падает в 409. Все продажи этой точки
+/// в периоде [OpenedAt, ClosedAt ?? now] привязываются к этой смене
+/// (RetailSale.CashShiftId, добавляем в Phase2 миграции).
+///
+/// При закрытии кассир вводит — фактическую
+/// сумму в ящике. Expected считается из +
+/// Σ cash-операций смены (продажи − возвраты + внесения − выплаты).
+/// Разница в подсвечивается в UI;
+/// отрицательная = недостача, положительная = излишек. Z-отчёт — просто
+/// печатный вид этой записи + свои аггрегаты по картам/QR.
+public class CashShift : TenantEntity, IVersionedEntity
+{
+ public uint Xmin { get; set; }
+
+ public string Number { get; set; } = "";
+
+ public CashShiftStatus Status { get; set; } = CashShiftStatus.Open;
+
+ /// Точка продаж, на которой открыта смена.
+ public Guid RetailPointId { get; set; }
+ public RetailPoint RetailPoint { get; set; } = null!;
+
+ /// Счёт «касса» этой точки — на нём отражается физический нал
+ /// смены (внесения/выплаты, продажи наличными). Выбирается из
+ /// где Kind=Cash + StoreId совпадает с
+ /// RetailPoint.StoreId. Если несколько касс — кассир выбирает в
+ /// момент открытия.
+ public Guid CashAccountId { get; set; }
+ public MoneyAccount CashAccount { get; set; } = null!;
+
+ /// Счёт «эквайринг» для карточной выручки. Опционально —
+ /// если магазин не берёт карты, смена работает только с наличными.
+ 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; }
+
+ /// «Разменник» на начало смены — сумма наличных, которую
+ /// кассир положил в ящик перед первой продажей (например, 20 000₸
+ /// разменными купюрами). Берётся из показателя предыдущей закрытой
+ /// смены, кассир может скорректировать.
+ public decimal OpeningFloat { get; set; }
+
+ /// Снапшоты на момент закрытия. До Close все == 0.
+ 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; }
+
+ /// Фактическая сумма наличных в ящике при закрытии (вводит
+ /// кассир). Null пока смена открыта.
+ public decimal? ActualCash { get; set; }
+
+ /// ActualCash − ExpectedCash. Знак: <0 — недостача,
+ /// >0 — излишек. Снапшот на момент закрытия.
+ public decimal CashDifference { get; set; }
+
+ public string? Notes { get; set; }
+
+ public ICollection DrawerOperations { get; set; } = new List();
+}
+
+/// Направление ящикового движения кассы. In — кассир положил
+/// деньги в ящик («внесение», «разменять из сейфа»). Out — выдал
+/// («сдача инкассатору», «выдал курьеру»). Логика расчёта смены
+/// соответствует МойСкладовским retaildrawercashin/retaildrawercashout.
+public enum CashDrawerDirection
+{
+ In = 1,
+ Out = 2,
+}
+
+/// Внесение/выплата внутри смены — простая запись, которую может
+/// сделать только тот кассир, у которого открыта смена, и только пока
+/// она открыта. После Close CashShift становится неизменяемой — операции
+/// привязаны намертво и попадают в Z-отчёт.
+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; }
+
+ /// Причина — обязательный свободный текст («Размен из сейфа»,
+ /// «Инкассация», «Выдал курьеру за доставку»). Пусто не принимаем.
+ public string Reason { get; set; } = null!;
+
+ public DateTime At { get; set; } = DateTime.UtcNow;
+ public Guid ByUserId { get; set; }
+}
diff --git a/src/food-market.domain/Money/CounterpartyAdjustment.cs b/src/food-market.domain/Money/CounterpartyAdjustment.cs
new file mode 100644
index 0000000..5168358
--- /dev/null
+++ b/src/food-market.domain/Money/CounterpartyAdjustment.cs
@@ -0,0 +1,45 @@
+using foodmarket.Domain.Catalog;
+using foodmarket.Domain.Common;
+
+namespace foodmarket.Domain.Money;
+
+/// Направление корректировки взаиморасчётов: WeOwe — мы должны
+/// контрагенту (увеличивает долг нам), TheyOwe — контрагент должен нам
+/// (уменьшает долг). Соответствует MoySkladовскому
+/// counterpartyadjustment, где знак выражается через sum.
+public enum CounterpartyAdjustmentDirection
+{
+ WeOwe = 1,
+ TheyOwe = 2,
+}
+
+/// Корректировка взаиморасчётов — для стартовых долгов и ручных
+/// исправлений. В простом режиме главное применение: «мы начинаем работать
+/// в системе, но с поставщиком Х у нас уже есть долг 500 000₸».
+///
+/// Проведённая корректировка влияет на отчёт «Долги поставщикам» (прибавляет
+/// или вычитает), на баланс счетов НЕ влияет — это чисто учётный документ,
+/// без денежного движения.
+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; }
+
+ /// Проведено (учитывается в отчётах) или черновик.
+ public MoneyTransactionStatus Status { get; set; } = MoneyTransactionStatus.Draft;
+}
diff --git a/src/food-market.domain/Money/ExpenseItem.cs b/src/food-market.domain/Money/ExpenseItem.cs
new file mode 100644
index 0000000..4e3cafe
--- /dev/null
+++ b/src/food-market.domain/Money/ExpenseItem.cs
@@ -0,0 +1,29 @@
+using foodmarket.Domain.Common;
+
+namespace foodmarket.Domain.Money;
+
+/// Статья расходов (у МойСклада — expenseItem). Привязывается
+/// к расходным документам: paymentout, cashout. Сид создаёт стартовый набор
+/// (Закупка товара, Аренда, Зарплата, Коммунальные, Прочее) с
+/// =true — его переименовать можно, удалить нет.
+///
+/// Магазин может добавить свои (например, «Инкассация», «Хоз-товары») —
+/// у них =false, удаляются при условии «ни на одном
+/// документе не используется» (валидатор в контроллере).
+public class ExpenseItem : TenantEntity, IVersionedEntity
+{
+ public uint Xmin { get; set; }
+
+ public string Name { get; set; } = null!;
+
+ /// Для UI: короткий код (например, "PURCHASE", "RENT"). Нужен
+ /// в основном сидеру + для сверки с 1С/МойСкладом при синхронизации.
+ /// Пользователь может оставить пустым.
+ public string? Code { get; set; }
+
+ public bool IsSystem { get; set; }
+
+ public bool IsActive { get; set; } = true;
+
+ public string? Notes { get; set; }
+}
diff --git a/src/food-market.domain/Money/MoneyAccount.cs b/src/food-market.domain/Money/MoneyAccount.cs
new file mode 100644
index 0000000..cadfb54
--- /dev/null
+++ b/src/food-market.domain/Money/MoneyAccount.cs
@@ -0,0 +1,81 @@
+using foodmarket.Domain.Catalog;
+using foodmarket.Domain.Common;
+
+namespace foodmarket.Domain.Money;
+
+/// Что это за «место, где лежат деньги». У МойСклада наличные и
+/// банковские счета — две разные сущности (cashbox-less для cash,
+/// organizationAccount для безнала); у нас в простом режиме магазину
+/// проще видеть единый справочник «Счета и кассы», поэтому различаем через
+/// :
+///
+/// - Cash — физическая касса магазина (деньги в ящике ККМ).
+/// Привязана к : деньги смены этой точки
+/// отражаются именно на этом счёте.
+/// - Safe — сейф, обычно у директора/бухгалтера. Деньги попадают
+/// сюда через инкассации «касса → сейф».
+/// - Bank — расчётный счёт юрлица (Kaspi, Halyk и т.п.).
+/// На один магазин допускается несколько банковских счетов.
+/// - Acquiring — счёт эквайринга: куда банк зачисляет оплаты
+/// картой с POS. Как правило отдельный от основного расчётного, с
+/// задержкой перевода T+1 и своей комиссией.
+///
+/// Начальный остаток фиксируется на дату
+/// и считается «данностью» — все проведённые
+/// движения прибавляются/вычитаются от него. Инвариант:
+/// CurrentBalance = OpeningBalance + Σ MoneyTransactions.Signed.
+/// Проверяется в MoneyReportsController.
+public enum MoneyAccountKind
+{
+ Cash = 1,
+ Safe = 2,
+ Bank = 3,
+ Acquiring = 4,
+}
+
+public class MoneyAccount : TenantEntity, IVersionedEntity
+{
+ public uint Xmin { get; set; }
+
+ /// Отображаемое имя: «Касса магазина Абая», «Kaspi основной»,
+ /// «Сейф директора». Уникальность — только в рамках организации
+ /// (разные магазины одной орги могут иметь «Касса»).
+ public string Name { get; set; } = null!;
+
+ public MoneyAccountKind Kind { get; set; }
+
+ /// Банк: только для и
+ /// . Для Cash/Safe — null.
+ public string? BankName { get; set; }
+
+ /// Номер счёта. Для банк./эквайринга — IBAN или просто номер.
+ /// Опционален (магазин может не хотеть вводить).
+ public string? AccountNumber { get; set; }
+
+ /// Привязка кассы к магазину. Для Bank/Safe обычно null (счета
+ /// юрлица, не магазина), для Cash — обязательна (нужна чтобы знать,
+ /// куда идёт выручка POS). Для Acquiring рекомендована, но не required.
+ public Guid? StoreId { get; set; }
+ public Store? Store { get; set; }
+
+ public Guid CurrencyId { get; set; }
+ public Currency Currency { get; set; } = null!;
+
+ /// Начальный остаток на дату старта учёта в системе. Задаётся
+ /// один раз при создании счёта; потом редактировать нельзя — иначе
+ /// расходится история (инвариант остатков будет врать). Если
+ /// действительно нужно переоткрыться — удалить все движения этого
+ /// счёта и пересоздать.
+ public decimal OpeningBalance { get; set; }
+ public DateTime OpeningDate { get; set; } = DateTime.UtcNow;
+
+ /// Если false — счёт виден только для чтения отчёта и не
+ /// предлагается в выпадающих списках новых документов. Удалять не
+ /// даём (есть движения), только архивация флажком.
+ public bool IsActive { get; set; } = true;
+
+ /// Удобный хинт для UI: по умолчанию выбирать именно этот
+ /// счёт при создании кассового/безнал. ордера. Один per
+ /// в орге.
+ public bool IsDefault { get; set; }
+}
diff --git a/src/food-market.domain/Money/MoneyTransaction.cs b/src/food-market.domain/Money/MoneyTransaction.cs
new file mode 100644
index 0000000..860f90b
--- /dev/null
+++ b/src/food-market.domain/Money/MoneyTransaction.cs
@@ -0,0 +1,135 @@
+using foodmarket.Domain.Catalog;
+using foodmarket.Domain.Common;
+using foodmarket.Domain.Purchases;
+
+namespace foodmarket.Domain.Money;
+
+/// Тип финансового документа — объединяет МойСкладовские
+/// paymentin, paymentout, cashin, cashout
+/// в одну сущность с дискриминатором.
+/// Переводы между своими счетами моделируем парой проводок
+/// (TransferOut + TransferIn), соединённых .
+public enum MoneyTransactionKind
+{
+ /// Приход безнала на банк. счёт (аналог paymentin).
+ PaymentIn = 1,
+
+ /// Расход безнала с банк. счёта (аналог paymentout).
+ PaymentOut = 2,
+
+ /// Приход наличных в кассу/сейф (аналог cashin).
+ CashIn = 3,
+
+ /// Расход наличных из кассы/сейфа (аналог cashout).
+ CashOut = 4,
+
+ /// Расходная половина перевода между своими счетами
+ /// (с ParentId → приходная половина).
+ TransferOut = 5,
+
+ /// Приходная половина перевода.
+ TransferIn = 6,
+}
+
+public enum MoneyTransactionStatus
+{
+ Draft = 0,
+ Posted = 1,
+}
+
+/// Единый финансовый документ: платёж/ордер/перевод. У каждого
+/// документа есть одна сторона () и знак:
+/// In-варианты увеличивают баланс счёта, Out — уменьшают.
+///
+/// Привязка к первичным документам (приёмкам/возвратам поставщика/продажам/
+/// возвратам покупателю) — через коллекцию , по образцу
+/// МойСкладовских operations[linkedSum]. На одной транзакции может
+/// быть N ссылок с суммами, разложенных по разным документам (частичная
+/// оплата допустима). Σ Links.LinkedSum не должна превышать Amount, но
+/// может быть меньше (кусок суммы «без закрытия документа» — аналог
+/// MoySklad noClosingDocs).
+///
+/// Проведённые транзакции (=Posted) неизменяемы;
+/// отмена = отдельный флаг =Draft + пересчёт
+/// Supply.PayedSum в сторону уменьшения, в одной транзакции
+/// Postgres (см. MoneyService.Unpost).
+public class MoneyTransaction : TenantEntity, IVersionedEntity
+{
+ public uint Xmin { get; set; }
+
+ /// Человекочитаемый номер, уникальный в орге. Генерим по шаблону
+ /// Д-{year}-{seq6} (Д как «деньги»), общий счётчик на все виды
+ /// MoneyTransaction — чтобы в списке «Платежи и ордера» документы шли
+ /// в порядке создания, а не по трём разным счётчикам.
+ public string Number { get; set; } = "";
+
+ public DateTime Date { get; set; } = DateTime.UtcNow;
+
+ public MoneyTransactionKind Kind { get; set; }
+
+ public MoneyTransactionStatus Status { get; set; } = MoneyTransactionStatus.Draft;
+
+ /// Счёт/касса, с которой это движение (приход — зачисление сюда,
+ /// расход — списание отсюда).
+ public Guid AccountId { get; set; }
+ public MoneyAccount Account { get; set; } = null!;
+
+ /// Контрагент: обязателен для PaymentIn/PaymentOut/CashIn/CashOut,
+ /// null для TransferIn/TransferOut (там контрагент — сами).
+ public Guid? CounterpartyId { get; set; }
+ public Counterparty? Counterparty { get; set; }
+
+ /// Статья расходов — только для PaymentOut/CashOut. У приходных
+ /// документов и переводов — null.
+ public Guid? ExpenseItemId { get; set; }
+ public ExpenseItem? ExpenseItem { get; set; }
+
+ public Guid CurrencyId { get; set; }
+ public Currency Currency { get; set; } = null!;
+
+ /// Сумма документа. Положительная (знак определяется Kind).
+ public decimal Amount { get; set; }
+
+ /// Назначение/основание платежа. У МойСклада обязательное
+ /// (`paymentPurpose`), но для простого магазина делаем необязательным —
+ /// кассир редко вписывает что-то кроме «За товар».
+ public string? Purpose { get; set; }
+
+ /// Общий комментарий документа.
+ public string? Notes { get; set; }
+
+ /// Для переводов — ссылка на парную половину (TransferOut↔TransferIn).
+ /// Проводятся всегда вместе, в одной БД-транзакции. Для всех остальных
+ /// Kind — null.
+ public Guid? LinkedTransactionId { get; set; }
+ public MoneyTransaction? LinkedTransaction { get; set; }
+
+ public DateTime? PostedAt { get; set; }
+ public Guid? PostedByUserId { get; set; }
+
+ public ICollection Links { get; set; } = new List();
+}
+
+/// Один ↔ один первичный документ с
+/// распределённой суммой. У MoySklad это operations[].linkedSum.
+///
+/// Для простого режима мы сейчас поддерживаем только привязку к
+/// (приёмка) и (возврат
+/// поставщику) — обе колонки nullable, ровно одна из них должна быть
+/// заполнена; валидируется в MoneyService.PostAsync и
+/// проверочным ограничением в БД. На Phase2 (расширенный режим) добавим
+/// Demand/RetailSale/CustomerReturn — тогда появятся соответствующие
+/// колонки, таблица расширится без миграции данных.
+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; }
+}
diff --git a/src/food-market.domain/Organizations/RolePermissions.cs b/src/food-market.domain/Organizations/RolePermissions.cs
index fbc0c99..53a4acc 100644
--- a/src/food-market.domain/Organizations/RolePermissions.cs
+++ b/src/food-market.domain/Organizations/RolePermissions.cs
@@ -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; }
+
/// Полный набор всех true — для системной роли «Администратор».
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,
};
}
diff --git a/src/food-market.domain/Purchases/SupplierReturn.cs b/src/food-market.domain/Purchases/SupplierReturn.cs
index f6d6595..3756af3 100644
--- a/src/food-market.domain/Purchases/SupplierReturn.cs
+++ b/src/food-market.domain/Purchases/SupplierReturn.cs
@@ -39,6 +39,11 @@ public class SupplierReturn : TenantEntity
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
+ /// Сколько поставщик вернул нам денег по этому возврату
+ /// (аналог ). Пересчитывает
+ /// MoneyService; инвариант 0 ≤ PayedSum ≤ Total.
+ public decimal PayedSum { get; set; }
+
public ICollection Lines { get; set; } = new List();
}
diff --git a/src/food-market.domain/Purchases/Supply.cs b/src/food-market.domain/Purchases/Supply.cs
index 5cea398..80cb32a 100644
--- a/src/food-market.domain/Purchases/Supply.cs
+++ b/src/food-market.domain/Purchases/Supply.cs
@@ -37,6 +37,14 @@ public class Supply : TenantEntity, IVersionedEntity
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
+ /// Сколько из оплачено — сумма всех
+ /// проведённых MoneyTransactionLink.LinkedSum, где
+ /// SupplyId=этой приёмки. Пересчитывается в MoneyService
+ /// при проведении/отмене платежа. Инвариант: 0 ≤ PayedSum ≤ Total.
+ /// Статус оплаты получаем сравнением: PayedSum==0 — не оплачена,
+ /// 0<PayedSum<Total — частично, PayedSum>=Total — оплачена.
+ public decimal PayedSum { get; set; }
+
public ICollection Lines { get; set; } = new List();
}
diff --git a/src/food-market.domain/Sales/RetailSale.cs b/src/food-market.domain/Sales/RetailSale.cs
index 1b3339b..618476f 100644
--- a/src/food-market.domain/Sales/RetailSale.cs
+++ b/src/food-market.domain/Sales/RetailSale.cs
@@ -71,6 +71,14 @@ public class RetailSale : TenantEntity, IVersionedEntity
public decimal PaidCash { get; set; }
public decimal PaidCard { get; set; }
+ /// Смена, в рамках которой проведён чек. Обязателен для
+ /// POS-продаж на точке с живой кассой; null у исторических чеков
+ /// до внедрения модуля «Деньги» и у безсменных операций (bulk-импорт,
+ /// технический seed, тесты). Проверка на обязательность — в
+ /// RetailSalesController.Post: если CashShift на точке
+ /// не открыта, продажа отклоняется с кодом cash-shift-required.
+ public Guid? CashShiftId { get; set; }
+
public string? Notes { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
diff --git a/src/food-market.infrastructure/Money/MoneyNumbering.cs b/src/food-market.infrastructure/Money/MoneyNumbering.cs
new file mode 100644
index 0000000..cd577ba
--- /dev/null
+++ b/src/food-market.infrastructure/Money/MoneyNumbering.cs
@@ -0,0 +1,48 @@
+using foodmarket.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace foodmarket.Infrastructure.Money;
+
+/// Генераторы человекочитаемых номеров для документов модуля «Деньги».
+/// Общий счётчик на все виды MoneyTransaction (чтобы в едином списке
+/// «Платежи и ордера» документы шли по возрастанию); отдельный — для смен и
+/// корректировок. Формат — префикс + год + 6-значный seq.
+///
+/// Реализация прочтением MAX номера под advisory-lock'ом (сериализация внутри
+/// одной орги). Для prod с десятками тысяч документов стоит переработать на
+/// отдельную таблицу-счётчик, но для простого режима и ожидаемой нагрузки
+/// такой подход экономит таблицу и работает корректно под Serializable.
+public static class MoneyNumbering
+{
+ public static Task NextMoneyTxNumberAsync(AppDbContext db, Guid orgId, int year, CancellationToken ct)
+ => NextByPrefixAsync(db, "money_transactions", "Д", orgId, year, ct);
+
+ public static Task NextShiftNumberAsync(AppDbContext db, Guid orgId, int year, CancellationToken ct)
+ => NextByPrefixAsync(db, "cash_shifts", "С", orgId, year, ct);
+
+ public static Task NextAdjustmentNumberAsync(AppDbContext db, Guid orgId, int year, CancellationToken ct)
+ => NextByPrefixAsync(db, "counterparty_adjustments", "К", orgId, year, ct);
+
+ private static async Task 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(sql, orgId, pattern)
+ .SingleAsync(ct);
+ var next = maxSeq + 1;
+ return $"{prefix}-{year}-{next:D6}";
+ }
+}
diff --git a/src/food-market.infrastructure/Money/MoneyService.cs b/src/food-market.infrastructure/Money/MoneyService.cs
new file mode 100644
index 0000000..fc03a06
--- /dev/null
+++ b/src/food-market.infrastructure/Money/MoneyService.cs
@@ -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;
+
+/// Единая реализация . Внутри — Serializable
+/// транзакции на всю операцию (post/unpost/open/close/drawer); при конкуренте
+/// Postgres падает в 40001, миддлварь (SerializationConflictMiddleware)
+/// преобразует в 409. Любые изменения Supply.PayedSum / SupplierReturn.PayedSum
+/// идут только отсюда — чтобы инвариант «PayedSum ∈ [0,Total]» был централизованно
+/// защищён.
+public sealed class MoneyService : IMoneyService
+{
+ private readonly AppDbContext _db;
+ private readonly ITenantContext _tenant;
+ private readonly ILogger _log;
+
+ public MoneyService(AppDbContext db, ITenantContext tenant, ILogger log)
+ {
+ _db = db;
+ _tenant = tenant;
+ _log = log;
+ }
+
+ public async Task 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 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 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 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 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);
+ }
+}
diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs
index 93448da..8d49aef 100644
--- a/src/food-market.infrastructure/Persistence/AppDbContext.cs
+++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs
@@ -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 options, ITenantContext tenan
public DbSet UserPresets => Set();
public DbSet OrgExports => Set();
+ // Sprint 33: модуль «Деньги» — простой режим.
+ public DbSet MoneyAccounts => Set();
+ public DbSet ExpenseItems => Set();
+ public DbSet MoneyTransactions => Set();
+ public DbSet MoneyTransactionLinks => Set();
+ public DbSet CashShifts => Set();
+ public DbSet CashDrawerOperations => Set();
+ public DbSet CounterpartyAdjustments => Set();
+
/// Если true — не пишет audit-строки
/// для этого SaveChanges. Используется сидерами/миграциями, фоновыми
/// импортами (где tenant-context отсутствует) и при ручной чистке логов.
@@ -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) —
diff --git a/src/food-market.infrastructure/Persistence/Configurations/MoneyConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/MoneyConfigurations.cs
new file mode 100644
index 0000000..227534a
--- /dev/null
+++ b/src/food-market.infrastructure/Persistence/Configurations/MoneyConfigurations.cs
@@ -0,0 +1,176 @@
+using foodmarket.Domain.Money;
+using Microsoft.EntityFrameworkCore;
+
+namespace foodmarket.Infrastructure.Persistence.Configurations;
+
+/// Схема таблиц модуля «Деньги». Названия колонок/таблиц
+/// подчинены общему стилю (snake_case через ToTable + EF-camel для свойств);
+/// суммовые — decimal(18,4); концуррентный токен — Postgres xmin.
+public static class MoneyConfigurations
+{
+ public static void ConfigureMoney(this ModelBuilder b)
+ {
+ b.Entity(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(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(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(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(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(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(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(e =>
+ {
+ e.Property(x => x.PayedSum).HasPrecision(18, 4);
+ e.HasIndex(x => new { x.OrganizationId, x.SupplierId, x.Status, x.PayedSum });
+ });
+
+ b.Entity(e =>
+ {
+ e.Property(x => x.PayedSum).HasPrecision(18, 4);
+ });
+
+ b.Entity(e =>
+ {
+ e.HasIndex(x => new { x.OrganizationId, x.CashShiftId })
+ .HasFilter("\"CashShiftId\" IS NOT NULL");
+ });
+ }
+}
diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261007100000_Phase33a_Money.cs b/src/food-market.infrastructure/Persistence/Migrations/20261007100000_Phase33a_Money.cs
new file mode 100644
index 0000000..3c36c34
--- /dev/null
+++ b/src/food-market.infrastructure/Persistence/Migrations/20261007100000_Phase33a_Money.cs
@@ -0,0 +1,233 @@
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using foodmarket.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace foodmarket.Infrastructure.Persistence.Migrations
+{
+ /// Phase33a — модуль «Деньги» для простого режима.
+ ///
+ /// Создаёт все таблицы модуля (money_accounts, expense_items,
+ /// money_transactions, money_transaction_links, cash_shifts,
+ /// cash_drawer_operations, counterparty_adjustments) и расширяет:
+ ///
+ ///
+ /// - supplies — колонка PayedSum (NUMERIC(18,4) DEFAULT 0),
+ /// с индексом по (OrganizationId, SupplierId, Status, PayedSum).
+ /// - supplier_returns — колонка PayedSum.
+ /// - retail_sales — колонка CashShiftId (nullable uuid)
+ /// с условным индексом WHERE CashShiftId IS NOT NULL.
+ ///
+ ///
+ /// Идемпотентно (IF NOT EXISTS везде); backfilling PayedSum=0
+ /// корректен, т.к. до этой фазы ни одна приёмка не могла быть оплачена
+ /// через модуль Денег.
+ [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;
+ ");
+ }
+ }
+}