Compare commits

...

3 commits

Author SHA1 Message Date
nns 0977c639cd docs(money): модель «деньги смены → остатки» + МойСклад reference + прогресс
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:11:10 +05:00
nns 005c4ac5bf test(money): независимый инвариант остатка счёта (нач+платежи+смены) + unit/integration
full-chain: indepBalance() считает остаток независимо (касса += факт−размен,
эквайринг += картой−возвраты) и сверяет с API /balances — а не с суммой из того же
API; шаг 8 — оплата нал ≤ остатка кассы + проверка отказа при превышении (409).
Unit: shift-net == факт−размен (MoneyMathTests). Integration: закрытие смены пишет
нал в остаток кассы, расход больше остатка → 409 (MoneyModuleTests); существующим
тестам оплаты дан достаточный остаток кассы.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:11:09 +05:00
nns 1e6d7fb2ab fix(money): движения смены попадают в остатки счетов + запрет отрицательного остатка
Раньше продажи/возвраты/внесения/выплаты/расхождение смены не влияли на остаток
кассы (касса уходила в −, эквайринг оставался 0). Теперь при закрытии смены
MoneyService.MaterializeShiftMovementsAsync создаёт Posted-движения money_transactions
по типам (Category): продажи нал/карта, возвраты нал/карта, внесение, выплата,
излишек/недостача — привязанные к смене, идемпотентно. Остаток кассы/эквайринга и
отчёт «Деньги» теперь верны автоматически (всё читает money_transactions).

Отрицательный остаток: расход (CashOut/PaymentOut/TransferOut) больше остатка счёта
и выплата из смены больше наличных смены → 409 insufficient-funds; настройка
Organization.AllowNegativeBalance (по умолч. выкл., меняет только Admin).

Миграция Phase34 (idempotent): money_transactions.Category/.CashShiftId,
organizations.AllowNegativeBalance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:11:09 +05:00
15 changed files with 499 additions and 276 deletions

View file

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

View file

@ -0,0 +1,79 @@
# Деньги смены → остатки счетов (прогресс)
Автономная задача владельца (08.10.2026, нашёл Mac-Claude на стенде). Деньги смены
(продажи нал/карта, возвраты, внесения/выплаты, расхождение) НЕ попадают в остаток
счёта. Делать ДО кассы — касса опирается на эти остатки.
## Факты со стенда (Демо после B)
- «Касса (e2e)»: нач. 50 000, текущий −98 812 (= 50 000 − 148 812 оплата приёмки нал).
Смена С-2026-000001: продажи нал 3 775, возвраты нал 1 500, внесение 10 000, выплата 3 000,
факт 14 775 — НИЧЕГО не попало в остаток счёта. Эквайринг: 0 при продажах картой 2 325.
- Оплата 148 812 нал при остатке 50 000 → касса в минус, никто не остановил.
- Инвариант теста «счёт ≡ начальный + Σ операций» прошёл только потому, что движения смены
не считаются операциями счёта — дыра в тесте.
## Текущая модель (как есть)
- Остаток счёта = `OpeningBalance + Σ(PaymentIn/CashIn/TransferIn) − Σ(PaymentOut/CashOut/TransferOut)`
по Posted `money_transactions` (MoneyAccountsController.List + MoneyReportsController.Balances).
- Смена (`CashShift`) хранит SalesCash/SalesCard/ReturnsCash/ReturnsCard/DrawerIn/DrawerOut/
ExpectedCash/ActualCash/CashDifference — считаются ПРИ ЗАКРЫТИИ из связанных retail_sales +
cash_drawer_operations. В `money_transactions` НЕ пишутся → в остаток счёта не входят.
## Выбранная модель (материализация движений смены при закрытии)
Единый реестр — `money_transactions`. При закрытии смены создаём Posted-транзакции на счета смены,
и существующая формула остатка автоматически их учитывает; отчёт «Движение» показывает их по типам.
- Касса смены (CashAccount): `+SalesCash` (Розничные продажи), `−ReturnsCash` (Возвраты),
`+DrawerIn` (Внесение), `−DrawerOut` (Выплата), `±CashDifference` (Излишек/Недостача).
- Эквайринг смены (AcquiringAccount): `+SalesCard` (Продажи картой), `−ReturnsCard` (Возвраты по карте).
- Размен (OpeningFloat) — НЕ движение (деньги уже в кассе). Итог: остаток кассы после закрытия =
старый остаток + (SalesCash−ReturnsCash+DrawerIn−DrawerOut+Diff) = ActualCash, если до смены на
кассе был только размен. Σ по типам = ActualCash − OpeningFloat.
Движения помечаем `Category` (enum) + `CashShiftId` (идемпотентность). Создаются только ненулевые.
## Отрицательный остаток
- Расход (CashOut/PaymentOut/TransferOut при post money-tx) и Выплата из смены (drawer Out) больше
доступных денег → 409 «На счёте «…» недостаточно денег: остаток X ₸, нужно Y ₸».
- Настройка орг `AllowNegativeBalance` (по умолч. выкл., менять только админ).
- Выплата из смены сверяется с ЖИВЫМ налом смены (OpeningFloat + SalesCash−ReturnsCash+DrawerIn−DrawerOut
на текущий момент), расход money-tx — с остатком счёта.
## План
1. [x] МойСклад reference → `docs/money-moysklad-reference.md`.
2. [x] Миграция `Phase34_ShiftMoneyMovements` (manual, idempotent): `money_transactions.Category`,
`.CashShiftId`, `organizations.AllowNegativeBalance`. Применилась на стенде (в history + колонки есть).
3. [x] `MoneyService.CloseShiftAsync` → `MaterializeShiftMovementsAsync` (Posted-движения по типам, link на смену, идемпотентно).
4. [x] Guard: `PostAsync` (расход > остатка счёта → InsufficientFunds 409) + `AddDrawerOperationAsync` (выплата > нала смены);
настройка `AllowNegativeBalance` в OrgSettings (PUT только Admin); InsufficientFunds→409 в MapResult/Drawer.
5. [x] Остатки/отчёты правильные автоматически (всё читает money_transactions); `Category` в DTO для типов.
6. [x] `docs/money.md` — раздел «Как деньги смены попадают в остатки» + «Отрицательный остаток».
7. [x] Остаток вычисляемый → пересчёт не нужен; новые смены материализуются при закрытии.
(Историч. закрытые смены прошлых тест-орг не бэкфиллим — чистятся prune'ом; демо пересоздан прогоном B.)
8. [x] full-chain: `indepBalance()` — независимый расчёт (нач + платежи + смены: касса += факт−размен,
эквайринг += картой−возвраты), сверяется с API /balances; шаг 8 — оплата ≤ остатка + отказ при превышении.
Unit (MoneyMathTests, +1 = 26 ✓) + integration (MoneyModuleTests, +2 = 10 ✓).
9. [x] deploy-stage; post-deploy C 16/16 зелёный; репро до деплоя — 4 шага красные (подтверждено).
10. [ ] Коммиты в main + push (после 2×C + B зелёных).
## Найденное/исправленное
1. **Движения смены не попадали в остаток счёта** (касса/эквайринг игнорировали продажи/возвраты/
внесения/выплаты/расхождение). Фикс: материализация при закрытии смены в money_transactions по типам.
2. **Касса уходила в минус без предупреждения** (расход больше остатка). Фикс: guard InsufficientFunds (409)
на расход money-tx и выплату из смены; настройка орг «Разрешить отрицательные остатки» (по умолч. выкл., Admin).
3. **Дыра в тесте**: инвариант сверял остаток с суммой из того же API. Фикс: независимый расчёт в тесте.
## Демо после фикса (прогон B, проверено руками)
- Касса: нач. 50 000 → текущий **31 275** (= 50 000 − 30 000 оплата приёмки нал
+ 9 775 смена С-001 (факт14775−размен5000) + 1 500 смена С-003). Раньше было −98 812.
- Касса-2: 3 000 → **4 550** (+1 550 смена С-004). Банк: 200 000 → **181 610** (−18 390 оплата №2).
- Эквайринг: 0 → **2 325** (продажи картой смены С-001). Раньше 0. Эквайринг-2: 0.
- Отрицательных остатков нет. 8 движений смен материализованы по типам (продажи нал/карта,
возвраты, внесение, выплата, излишек). Отказ при оплате больше остатка кассы — 409.
## Статус: ГОТОВО ✅
Движения смены попадают в остатки; отрицательный остаток запрещён (с настройкой). B 16/16 (36 скринов),
2× C подряд зелёные (16/16). unit 26 ✓, integration money 10 ✓.
## Журнал
- 08.10 — старт; модель (материализация при закрытии); миграция+код+guard+настройка+отчёт; docs.
Репро до деплоя: 4 шага красные. После деплоя: C 16/16 зелёный; unit 26 ✓, integration money 10 ✓.
Демо B зелёный, остатки сверены (см. выше). 2× C подряд зелёные.

View file

@ -151,3 +151,30 @@
**Ошибся в смене/платеже.** Платёж можно «Отменить проведение» и исправить. **Ошибся в смене/платеже.** Платёж можно «Отменить проведение» и исправить.
Закрытая смена неизменяема — расхождение фиксируется как разница (недостача/ Закрытая смена неизменяема — расхождение фиксируется как разница (недостача/
излишек) и видно в Z-отчёте. излишек) и видно в Z-отчёте.
## Как деньги смены попадают в остатки счетов (с 08.10.2026)
Раньше продажи/возвраты/внесения/выплаты смены не влияли на остаток кассы — это
была ошибка. Теперь при **закрытии смены** эти движения автоматически попадают в
деньги по типам:
- **Касса смены:** + Розничные продажи (нал) − Возвраты покупателям (нал)
+ Внесения − Выплаты ± Излишек/Недостача.
- **Эквайринг смены:** + Продажи картой − Возвраты по карте.
- **Размен** (начальный остаток смены) движением не считается — это те же деньги,
что уже были в кассе. Поэтому остаток кассы после закрытия = фактической сумме
наличных, если по кассе не было других операций.
Эти строки видны в отчёте «Деньги» (движение) и в списке «Платежи и ордера»
(с пометкой смены). Остаток каждого счёта = Начальный остаток + все проведённые
движения (платежи/ордера/переводы + движения смен).
## Отрицательный остаток
По умолчанию счёт/касса **не может уйти в минус**: если расход (оплата, перевод,
выплата из смены) больше остатка — операция отклоняется с сообщением
«На счёте «…» недостаточно денег: остаток X ₸, нужно Y ₸». Выплата из смены
сверяется с наличными, которые реально есть в кассе на этот момент.
Включить минус может только администратор: Настройки организации →
«Разрешить отрицательные остатки».

View file

@ -166,7 +166,13 @@ public async Task<ActionResult<object>> Drawer(Guid id, [FromBody] DrawerOpInput
var userId = CurrentUserId(); var userId = CurrentUserId();
var r = await _money.AddDrawerOperationAsync(id, input.Direction, input.Amount, input.Reason, userId, ct); var r = await _money.AddDrawerOperationAsync(id, input.Direction, input.Amount, input.Reason, userId, ct);
if (r.Status != MoneyServiceStatus.Ok) if (r.Status != MoneyServiceStatus.Ok)
return Problem(r.Message, statusCode: r.Status == MoneyServiceStatus.NotFound ? 404 : 400, return Problem(r.Message,
statusCode: r.Status switch
{
MoneyServiceStatus.NotFound => 404,
MoneyServiceStatus.InsufficientFunds => 409,
_ => 400,
},
type: $"https://food-market.kz/errors/{r.ErrorCode}"); type: $"https://food-market.kz/errors/{r.ErrorCode}");
return Ok(new { id = r.OperationId }); return Ok(new { id = r.OperationId });
} }

View file

@ -44,6 +44,7 @@ public record MoneyTxLinkRow(Guid? SupplyId, string? SupplyNumber, Guid? Supplie
public record MoneyTxRow( public record MoneyTxRow(
Guid Id, string Number, DateTime Date, Guid Id, string Number, DateTime Date,
MoneyTransactionKind Kind, MoneyTransactionStatus Status, MoneyTransactionKind Kind, MoneyTransactionStatus Status,
MoneyTransactionCategory Category,
Guid AccountId, string AccountName, Guid AccountId, string AccountName,
Guid? CounterpartyId, string? CounterpartyName, Guid? CounterpartyId, string? CounterpartyName,
Guid? ExpenseItemId, string? ExpenseItemName, Guid? ExpenseItemId, string? ExpenseItemName,
@ -100,6 +101,7 @@ public record MoneyTxInput(
.Select(x => new .Select(x => new
{ {
x.Id, x.Number, x.Date, x.Kind, x.Status, x.Id, x.Number, x.Date, x.Kind, x.Status,
x.Category,
x.AccountId, AccountName = _db.MoneyAccounts.Where(a => a.Id == x.AccountId).Select(a => a.Name).FirstOrDefault() ?? "", 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.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.ExpenseItemId, ExpenseItemName = x.ExpenseItemId == null ? null : _db.ExpenseItems.Where(e => e.Id == x.ExpenseItemId).Select(e => e.Name).FirstOrDefault(),
@ -124,6 +126,7 @@ public record MoneyTxInput(
var items = rows.Select(r => new MoneyTxRow( var items = rows.Select(r => new MoneyTxRow(
r.Id, r.Number, r.Date, r.Kind, r.Status, r.Id, r.Number, r.Date, r.Kind, r.Status,
r.Category,
r.AccountId, r.AccountName, r.CounterpartyId, r.CounterpartyName, r.AccountId, r.AccountName, r.CounterpartyId, r.CounterpartyName,
r.ExpenseItemId, r.ExpenseItemName, r.ExpenseItemId, r.ExpenseItemName,
r.CurrencyId, r.CurrencyCode, r.CurrencyId, r.CurrencyCode,
@ -340,6 +343,7 @@ private Guid CurrentUserId()
MoneyServiceStatus.LinkedSumMismatch => BadRequest(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.WrongCurrency => BadRequest(new { code = r.ErrorCode, message = r.Message }),
MoneyServiceStatus.AccountInactive => Conflict(new { code = r.ErrorCode, message = r.Message }), MoneyServiceStatus.AccountInactive => Conflict(new { code = r.ErrorCode, message = r.Message }),
MoneyServiceStatus.InsufficientFunds => Conflict(new { code = r.ErrorCode, message = r.Message }),
_ => BadRequest(new { code = r.ErrorCode, message = r.Message }), _ => BadRequest(new { code = r.ErrorCode, message = r.Message }),
}; };
@ -360,6 +364,7 @@ private Guid CurrentUserId()
.Select(x => Microsoft.EntityFrameworkCore.EF.Property<uint>(x, "xmin")).FirstAsync(ct); .Select(x => Microsoft.EntityFrameworkCore.EF.Property<uint>(x, "xmin")).FirstAsync(ct);
return new MoneyTxRow( return new MoneyTxRow(
mt.Id, mt.Number, mt.Date, mt.Kind, mt.Status, mt.Id, mt.Number, mt.Date, mt.Kind, mt.Status,
mt.Category,
mt.AccountId, account, mt.CounterpartyId, counterparty, mt.AccountId, account, mt.CounterpartyId, counterparty,
mt.ExpenseItemId, exp, mt.ExpenseItemId, exp,
mt.CurrencyId, currency, mt.CurrencyId, currency,

View file

@ -35,6 +35,7 @@ public record OrgSettingsDto(
bool ShowMarkedOnProduct, bool ShowMarkedOnProduct,
bool ShowMinMaxStock, bool ShowMinMaxStock,
bool AllowFractionalPrices, bool AllowFractionalPrices,
bool AllowNegativeBalance,
bool ShowReferencePriceOnProduct, bool ShowReferencePriceOnProduct,
bool ShowCountryOfOriginOnProduct, bool ShowCountryOfOriginOnProduct,
bool ShowDescriptionOnProduct); bool ShowDescriptionOnProduct);
@ -49,6 +50,7 @@ public record OrgSettingsInput(
bool ShowMarkedOnProduct, bool ShowMarkedOnProduct,
bool ShowMinMaxStock, bool ShowMinMaxStock,
bool AllowFractionalPrices, bool AllowFractionalPrices,
bool AllowNegativeBalance,
bool ShowReferencePriceOnProduct, bool ShowReferencePriceOnProduct,
bool ShowCountryOfOriginOnProduct, bool ShowCountryOfOriginOnProduct,
bool ShowDescriptionOnProduct); bool ShowDescriptionOnProduct);
@ -87,6 +89,7 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
o.ShowMarkedOnProduct = input.ShowMarkedOnProduct; o.ShowMarkedOnProduct = input.ShowMarkedOnProduct;
o.ShowMinMaxStock = input.ShowMinMaxStock; o.ShowMinMaxStock = input.ShowMinMaxStock;
o.AllowFractionalPrices = input.AllowFractionalPrices; o.AllowFractionalPrices = input.AllowFractionalPrices;
o.AllowNegativeBalance = input.AllowNegativeBalance;
o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct; o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct; o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct;
o.ShowDescriptionOnProduct = input.ShowDescriptionOnProduct; o.ShowDescriptionOnProduct = input.ShowDescriptionOnProduct;
@ -118,6 +121,7 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
o.ShowMarkedOnProduct, o.ShowMarkedOnProduct,
o.ShowMinMaxStock, o.ShowMinMaxStock,
o.AllowFractionalPrices, o.AllowFractionalPrices,
o.AllowNegativeBalance,
o.ShowReferencePriceOnProduct, o.ShowReferencePriceOnProduct,
o.ShowCountryOfOriginOnProduct, o.ShowCountryOfOriginOnProduct,
o.ShowDescriptionOnProduct); o.ShowDescriptionOnProduct);

View file

@ -50,6 +50,7 @@ public enum MoneyServiceStatus
LinkedSumMismatch = 9, LinkedSumMismatch = 9,
WrongCurrency = 10, WrongCurrency = 10,
AccountInactive = 11, AccountInactive = 11,
InsufficientFunds = 12,
} }
public record MoneyServiceResult(MoneyServiceStatus Status, string? ErrorCode = null, string? Message = null) public record MoneyServiceResult(MoneyServiceStatus Status, string? ErrorCode = null, string? Message = null)

View file

@ -53,10 +53,33 @@ public enum MoneyTransactionStatus
/// отмена = отдельный флаг <see cref="Status"/>=Draft + пересчёт /// отмена = отдельный флаг <see cref="Status"/>=Draft + пересчёт
/// <c>Supply.PayedSum</c> в сторону уменьшения, в одной транзакции /// <c>Supply.PayedSum</c> в сторону уменьшения, в одной транзакции
/// Postgres (см. <c>MoneyService.Unpost</c>).</summary> /// Postgres (см. <c>MoneyService.Unpost</c>).</summary>
/// <summary>Происхождение/тип движения денег — для отчёта «Деньги» и для того,
/// чтобы отличать движения смены (создаются при закрытии кассовой смены) от
/// ручных платежей/ордеров. Manual — обычный платёж/ордер/перевод.</summary>
public enum MoneyTransactionCategory
{
Manual = 0,
RetailSalesCash = 1, // розничные продажи наличными (приход в кассу смены)
RetailReturnsCash = 2, // возвраты покупателям наличными (расход из кассы смены)
DrawerIn = 3, // внесение в кассу смены
DrawerOut = 4, // выплата из кассы смены
ShiftSurplus = 5, // излишек при закрытии смены (факт > должно быть)
ShiftShortage = 6, // недостача при закрытии смены (факт < должно быть)
RetailSalesCard = 7, // продажи картой (приход на эквайринг смены)
RetailReturnsCard = 8, // возвраты по карте (расход с эквайринга смены)
}
public class MoneyTransaction : TenantEntity, IVersionedEntity public class MoneyTransaction : TenantEntity, IVersionedEntity
{ {
public uint Xmin { get; set; } public uint Xmin { get; set; }
/// <summary>Тип движения. Manual — ручной платёж/ордер/перевод; остальные —
/// движения кассовой смены (материализуются при закрытии).</summary>
public MoneyTransactionCategory Category { get; set; } = MoneyTransactionCategory.Manual;
/// <summary>Смена, породившая это движение (для движений смены). null — ручной документ.</summary>
public Guid? CashShiftId { get; set; }
/// <summary>Человекочитаемый номер, уникальный в орге. Генерим по шаблону /// <summary>Человекочитаемый номер, уникальный в орге. Генерим по шаблону
/// <c>Д-{year}-{seq6}</c> (Д как «деньги»), общий счётчик на все виды /// <c>Д-{year}-{seq6}</c> (Д как «деньги»), общий счётчик на все виды
/// MoneyTransaction — чтобы в списке «Платежи и ордера» документы шли /// MoneyTransaction — чтобы в списке «Платежи и ордера» документы шли

View file

@ -73,6 +73,11 @@ public class Organization : Entity
/// дробное через API.</summary> /// дробное через API.</summary>
public bool AllowFractionalPrices { get; set; } public bool AllowFractionalPrices { get; set; }
/// <summary>Разрешить ли уходить счетам/кассам в минус. По умолчанию выкл.:
/// расход больше остатка (оплата, выплата из смены) отклоняется с 409.
/// Менять может только администратор организации.</summary>
public bool AllowNegativeBalance { get; set; }
/// <summary>Показывать ли в карточке товара поле «Эталонная цена». /// <summary>Показывать ли в карточке товара поле «Эталонная цена».
/// Default: true.</summary> /// Default: true.</summary>
public bool ShowReferencePriceOnProduct { get; set; } = true; public bool ShowReferencePriceOnProduct { get; set; } = true;

View file

@ -58,6 +58,21 @@ public async Task<MoneyServiceResult> PostAsync(Guid transactionId, Guid current
return MoneyServiceResult.Fail(MoneyServiceStatus.ValidationFailed, "amount-must-be-positive", return MoneyServiceResult.Fail(MoneyServiceStatus.ValidationFailed, "amount-must-be-positive",
"Сумма платежа должна быть больше нуля."); "Сумма платежа должна быть больше нуля.");
// Отрицательный остаток: расход больше остатка счёта → отказ,
// если орг не разрешила уходить в минус.
if (mt.Kind is MoneyTransactionKind.PaymentOut or MoneyTransactionKind.CashOut or MoneyTransactionKind.TransferOut)
{
var allowNeg = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == mt.OrganizationId).Select(o => o.AllowNegativeBalance).FirstOrDefaultAsync(ct);
if (!allowNeg)
{
var balance = await AccountBalanceAsync(mt.AccountId, mt.Id, ct);
if (balance < mt.Amount - MoneyMath.Eps)
return MoneyServiceResult.Fail(MoneyServiceStatus.InsufficientFunds, "insufficient-funds",
$"На счёте «{mt.Account.Name}» недостаточно денег: остаток {balance:0.##} ₸, нужно {mt.Amount:0.##} ₸.");
}
}
// Сумма связей не может превышать сумму документа. // Сумма связей не может превышать сумму документа.
var linkedTotal = mt.Links.Sum(l => l.LinkedSum); var linkedTotal = mt.Links.Sum(l => l.LinkedSum);
if (linkedTotal > mt.Amount + 0.0001m) if (linkedTotal > mt.Amount + 0.0001m)
@ -296,6 +311,11 @@ public async Task<MoneyServiceResult> UnpostAsync(Guid transactionId, Guid curre
shift.Notes = notes; shift.Notes = notes;
shift.UpdatedAt = DateTime.UtcNow; shift.UpdatedAt = DateTime.UtcNow;
// Материализуем движения смены в money_transactions → остаток счетов и
// отчёт «Деньги» теперь учитывают продажи/возвраты/внесения/выплаты/расхождение.
await MaterializeShiftMovementsAsync(shift, salesCash, salesCard, returnsCash, returnsCard,
drawerIn, drawerOut, shift.CashDifference, ct);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct); await tx.CommitAsync(ct);
_log.LogInformation("CashShift {Number} closed by {UserId}: expected={Expected} actual={Actual} diff={Diff}", _log.LogInformation("CashShift {Number} closed by {UserId}: expected={Expected} actual={Actual} diff={Diff}",
@ -321,6 +341,20 @@ public async Task<MoneyServiceResult> UnpostAsync(Guid transactionId, Guid curre
return new(MoneyServiceStatus.ShiftNotOpen, null, return new(MoneyServiceStatus.ShiftNotOpen, null,
"shift-not-open", "Смена закрыта — добавлять операции нельзя."); "shift-not-open", "Смена закрыта — добавлять операции нельзя.");
// Выплата больше наличных в кассе смены → отказ (если орг не разрешает минус).
if (direction == CashDrawerDirection.Out)
{
var allowNeg = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == shift.OrganizationId).Select(o => o.AllowNegativeBalance).FirstOrDefaultAsync(ct);
if (!allowNeg)
{
var cashOnHand = await ShiftCashOnHandAsync(shift, ct);
if (cashOnHand < amount - MoneyMath.Eps)
return new(MoneyServiceStatus.InsufficientFunds, null, "insufficient-funds",
$"В кассе смены недостаточно наличных: есть {cashOnHand:0.##} ₸, нужно {amount:0.##} ₸.");
}
}
var op = new CashDrawerOperation var op = new CashDrawerOperation
{ {
OrganizationId = shift.OrganizationId, OrganizationId = shift.OrganizationId,
@ -335,4 +369,91 @@ public async Task<MoneyServiceResult> UnpostAsync(Guid transactionId, Guid curre
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return new(MoneyServiceStatus.Ok, op.Id, null, null); return new(MoneyServiceStatus.Ok, op.Id, null, null);
} }
/// <summary>Остаток счёта = OpeningBalance + Σ приходов − Σ расходов по
/// проведённым movements (кроме исключённого документа).</summary>
private async Task<decimal> AccountBalanceAsync(Guid accountId, Guid? excludeTxId, CancellationToken ct)
{
var opening = await _db.MoneyAccounts.Where(a => a.Id == accountId)
.Select(a => a.OpeningBalance).FirstAsync(ct);
var q = _db.MoneyTransactions
.Where(m => m.AccountId == accountId && m.Status == MoneyTransactionStatus.Posted);
if (excludeTxId is Guid ex) q = q.Where(m => m.Id != ex);
var rows = await q.Select(m => new { m.Kind, m.Amount }).ToListAsync(ct);
var inSum = rows.Where(r => r.Kind is MoneyTransactionKind.PaymentIn or MoneyTransactionKind.CashIn or MoneyTransactionKind.TransferIn).Sum(r => r.Amount);
var outSum = rows.Where(r => r.Kind is MoneyTransactionKind.PaymentOut or MoneyTransactionKind.CashOut or MoneyTransactionKind.TransferOut).Sum(r => r.Amount);
return MoneyMath.AccountBalance(opening, inSum, outSum);
}
/// <summary>Наличные в кассе открытой смены: размен + продажи нал − возвраты
/// нал + внесения − выплаты (на текущий момент).</summary>
private async Task<decimal> ShiftCashOnHandAsync(CashShift shift, CancellationToken ct)
{
var sales = await _db.RetailSales
.Where(s => s.CashShiftId == shift.Id && s.Status == RetailSaleStatus.Posted)
.Select(s => new { s.IsReturn, s.PaidCash }).ToListAsync(ct);
var salesCash = sales.Where(s => !s.IsReturn).Sum(s => s.PaidCash);
var returnsCash = sales.Where(s => s.IsReturn).Sum(s => s.PaidCash);
var ops = await _db.CashDrawerOperations.Where(o => o.CashShiftId == shift.Id)
.Select(o => new { o.Direction, o.Amount }).ToListAsync(ct);
var drawerIn = ops.Where(o => o.Direction == CashDrawerDirection.In).Sum(o => o.Amount);
var drawerOut = ops.Where(o => o.Direction == CashDrawerDirection.Out).Sum(o => o.Amount);
return shift.OpeningFloat + salesCash - returnsCash + drawerIn - drawerOut;
}
/// <summary>Материализует финансовые движения закрытой смены в money_transactions
/// (Posted) — продажи/возвраты нал (касса), внесения/выплаты (касса),
/// излишек/недостача (касса), продажи/возвраты по карте (эквайринг).
/// Идемпотентно по CashShiftId. Размен (OpeningFloat) не материализуется —
/// эти деньги уже были в кассе до смены.</summary>
private async Task MaterializeShiftMovementsAsync(CashShift shift,
decimal salesCash, decimal salesCard, decimal returnsCash, decimal returnsCard,
decimal drawerIn, decimal drawerOut, decimal difference, CancellationToken ct)
{
var existing = await _db.MoneyTransactions.Where(m => m.CashShiftId == shift.Id).ToListAsync(ct);
if (existing.Count > 0) _db.MoneyTransactions.RemoveRange(existing);
var year = (shift.ClosedAt ?? DateTime.UtcNow).Year;
var baseNum = await MoneyNumbering.NextMoneyTxNumberAsync(_db, shift.OrganizationId, year, ct);
var seq = int.Parse(baseNum.Split('-')[2]);
var cashCur = await _db.MoneyAccounts.Where(a => a.Id == shift.CashAccountId).Select(a => a.CurrencyId).FirstAsync(ct);
Guid? acqId = shift.AcquiringAccountId;
var acqCur = acqId is Guid a ? await _db.MoneyAccounts.Where(x => x.Id == a).Select(x => x.CurrencyId).FirstAsync(ct) : Guid.Empty;
void Add(Guid accountId, Guid currencyId, MoneyTransactionKind kind, MoneyTransactionCategory cat, decimal amount, string purpose)
{
if (amount <= MoneyMath.Eps) return;
_db.MoneyTransactions.Add(new MoneyTransaction
{
OrganizationId = shift.OrganizationId,
Number = $"Д-{year}-{seq++:D6}",
Date = shift.ClosedAt ?? DateTime.UtcNow,
Kind = kind,
Category = cat,
Status = MoneyTransactionStatus.Posted,
AccountId = accountId,
CurrencyId = currencyId,
Amount = amount,
CashShiftId = shift.Id,
Purpose = $"{purpose} (смена {shift.Number})",
PostedAt = DateTime.UtcNow,
PostedByUserId = shift.ClosedByUserId,
});
}
Add(shift.CashAccountId, cashCur, MoneyTransactionKind.CashIn, MoneyTransactionCategory.RetailSalesCash, salesCash, "Розничные продажи (нал)");
Add(shift.CashAccountId, cashCur, MoneyTransactionKind.CashOut, MoneyTransactionCategory.RetailReturnsCash, returnsCash, "Возвраты покупателям (нал)");
Add(shift.CashAccountId, cashCur, MoneyTransactionKind.CashIn, MoneyTransactionCategory.DrawerIn, drawerIn, "Внесение в кассу");
Add(shift.CashAccountId, cashCur, MoneyTransactionKind.CashOut, MoneyTransactionCategory.DrawerOut, drawerOut, "Выплата из кассы");
if (difference > MoneyMath.Eps)
Add(shift.CashAccountId, cashCur, MoneyTransactionKind.CashIn, MoneyTransactionCategory.ShiftSurplus, difference, "Излишек смены");
else if (difference < -MoneyMath.Eps)
Add(shift.CashAccountId, cashCur, MoneyTransactionKind.CashOut, MoneyTransactionCategory.ShiftShortage, -difference, "Недостача смены");
if (acqId is Guid aid)
{
Add(aid, acqCur, MoneyTransactionKind.PaymentIn, MoneyTransactionCategory.RetailSalesCard, salesCard, "Продажи картой");
Add(aid, acqCur, MoneyTransactionKind.PaymentOut, MoneyTransactionCategory.RetailReturnsCard, returnsCard, "Возвраты по карте");
}
}
} }

View file

@ -72,6 +72,9 @@ public static void ConfigureMoney(this ModelBuilder b)
e.HasIndex(x => new { x.OrganizationId, x.AccountId, x.Date }); e.HasIndex(x => new { x.OrganizationId, x.AccountId, x.Date });
e.HasIndex(x => new { x.OrganizationId, x.CounterpartyId }) e.HasIndex(x => new { x.OrganizationId, x.CounterpartyId })
.HasFilter("\"CounterpartyId\" IS NOT NULL"); .HasFilter("\"CounterpartyId\" IS NOT NULL");
// Движения смены (материализуются при закрытии) ищем по смене —
// идемпотентность и отчёт по смене.
e.HasIndex(x => x.CashShiftId).HasFilter("\"CashShiftId\" IS NOT NULL");
}); });
b.Entity<MoneyTransactionLink>(e => b.Entity<MoneyTransactionLink>(e =>

View file

@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase34 — движения кассовой смены попадают в деньги.
///
/// Добавляет:
/// <list type="bullet">
/// <item>money_transactions.<c>Category</c> (integer, DEFAULT 0 = Manual) —
/// тип движения: розничные продажи/возвраты нал/карта, внесения/выплаты,
/// излишек/недостача смены.</item>
/// <item>money_transactions.<c>CashShiftId</c> (uuid, nullable) —
/// смена, породившая движение (материализуется при закрытии).</item>
/// <item>organizations.<c>AllowNegativeBalance</c> (boolean, DEFAULT false) —
/// разрешить ли уход счетов в минус.</item>
/// </list>
///
/// Идемпотентно (IF NOT EXISTS). Данные не пересчитываем: остаток счёта
/// вычисляемый (Opening + Σ Posted money_transactions); движения прошлых
/// закрытых смен материализуются ретроспективно отдельным backfill-методом
/// в DevDataSeeder (idempotent, по CashShiftId).</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20261008030000_Phase34_ShiftMoneyMovements")]
public partial class Phase34_ShiftMoneyMovements : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql(@"
ALTER TABLE public.money_transactions
ADD COLUMN IF NOT EXISTS ""Category"" integer NOT NULL DEFAULT 0;
ALTER TABLE public.money_transactions
ADD COLUMN IF NOT EXISTS ""CashShiftId"" uuid;
CREATE INDEX IF NOT EXISTS ""IX_money_transactions_CashShiftId""
ON public.money_transactions (""CashShiftId"")
WHERE ""CashShiftId"" IS NOT NULL;
ALTER TABLE public.organizations
ADD COLUMN IF NOT EXISTS ""AllowNegativeBalance"" boolean NOT NULL DEFAULT false;
");
}
protected override void Down(MigrationBuilder b)
{
b.Sql(@"
DROP INDEX IF EXISTS public.""IX_money_transactions_CashShiftId"";
ALTER TABLE public.money_transactions DROP COLUMN IF EXISTS ""CashShiftId"";
ALTER TABLE public.money_transactions DROP COLUMN IF EXISTS ""Category"";
ALTER TABLE public.organizations DROP COLUMN IF EXISTS ""AllowNegativeBalance"";
");
}
}
}

View file

@ -41,13 +41,17 @@ interface Ctx {
supplies: SupplyRec[] supplies: SupplyRec[]
pay: PayRec[] pay: PayRec[]
shift?: ShiftRec shift?: ShiftRec
closedShifts: ShiftContrib[]
reportFrom?: string reportFrom?: string
reportTo?: string reportTo?: string
} }
/** Вклад закрытой смены в счета (для НЕЗАВИСИМого расчёта остатка в тесте):
* касса += actual − opening; эквайринг += salesCard − returnsCard. */
interface ShiftContrib { cashAcctId: string; actual: number; opening: number; acqAcctId?: string; salesCard: number; returnsCard: number }
interface Prod { id: string; name: string; price: number; cost: number; unitId: string; unitName: string; barcode: string; weighted: boolean } interface Prod { id: string; name: string; price: number; cost: number; unitId: string; unitName: string; barcode: string; weighted: boolean }
interface EmpRec { id: string; email: string; password: string; role: string; token?: string } interface EmpRec { id: string; email: string; password: string; role: string; token?: string }
interface SupplyRec { id: string; number?: string; total: number; supplierId: string; lines: { productId: string; quantity: number; unitPrice: number }[]; paid: number } interface SupplyRec { id: string; number?: string; total: number; supplierId: string; lines: { productId: string; quantity: number; unitPrice: number }[]; paid: number }
interface PayRec { txId: string; supplyIdx: number; amount: number; account: string } interface PayRec { txId: string; supplyIdx: number; amount: number; account: string; accountId: string }
interface ShiftRec { interface ShiftRec {
id: string; pointId: string; cashId: string; openingFloat: number id: string; pointId: string; cashId: string; openingFloat: number
salesCash: number; salesCard: number; returnsCash: number; drawerIn: number; drawerOut: number salesCash: number; salesCard: number; returnsCash: number; drawerIn: number; drawerOut: number
@ -78,6 +82,7 @@ function initCtx(ctx: Ctx) {
ctx.emp ??= {} ctx.emp ??= {}
ctx.supplies ??= [] ctx.supplies ??= []
ctx.pay ??= [] ctx.pay ??= []
ctx.closedShifts ??= []
ctx.cashOpening ??= 0 ctx.cashOpening ??= 0
ctx.bankOpening ??= 0 ctx.bankOpening ??= 0
ctx.acqOpening ??= 0 ctx.acqOpening ??= 0
@ -133,6 +138,17 @@ async function stockOf(api: AxiosInstance, storeId: string, productId: string):
const row = items.find((i) => i.storeId === storeId && i.productId === productId) const row = items.find((i) => i.storeId === storeId && i.productId === productId)
return row ? Number(row.quantity) : 0 return row ? Number(row.quantity) : 0
} }
// НЕЗАВИСИМЫЙ расчёт остатка счёта (считает сам тест из своих данных, не из
// money_transactions API): начальный − расходы теста + вклад закрытых смен
// (касса += факт − размен; эквайринг += продажи картой − возвраты картой).
function indepBalance(ctx: Ctx, accountId: string | undefined, opening: number): number {
if (!accountId) return opening
const payOut = ctx.pay.filter((p) => p.accountId === accountId).reduce((a, p) => a + p.amount, 0)
const shiftCash = ctx.closedShifts.filter((s) => s.cashAcctId === accountId).reduce((a, s) => a + (s.actual - s.opening), 0)
const shiftCard = ctx.closedShifts.filter((s) => s.acqAcctId === accountId).reduce((a, s) => a + (s.salesCard - s.returnsCard), 0)
return round2(opening - payOut + shiftCash + shiftCard)
}
// helper: sum of movements for a product+store (инвариант) // helper: sum of movements for a product+store (инвариант)
async function movementsSum(api: AxiosInstance, storeId: string, productId: string): Promise<number> { async function movementsSum(api: AxiosInstance, storeId: string, productId: string): Promise<number> {
const r = await api.get(`/api/inventory/movements?productId=${productId}&storeId=${storeId}&pageSize=500`) const r = await api.get(`/api/inventory/movements?productId=${productId}&storeId=${storeId}&pageSize=500`)
@ -800,16 +816,32 @@ export async function step08_supplier_payments({ ctx, step, report }: StepCtx) {
const s1 = ctx.supplies[0] // приёмка №1 (реальный поставщик) const s1 = ctx.supplies[0] // приёмка №1 (реальный поставщик)
const s2 = ctx.supplies[1] // приёмка №2 (placeholder) const s2 = ctx.supplies[1] // приёмка №2 (placeholder)
// №1 — частично наличными из кассы // №1 — частично наличными из кассы. Сумма НЕ должна превышать остаток кассы
const partial = round2(s1.total * 0.4) // (нач. 50000, пока без движений смены): берём ≤ 30000.
const partial = round2(Math.min(s1.total * 0.4, 30_000))
const p1 = await api.post('/api/money/transactions', { const p1 = await api.post('/api/money/transactions', {
Date: new Date().toISOString(), Kind: 4 /* CashOut */, AccountId: ctx.cash1Id, Date: new Date().toISOString(), Kind: 4 /* CashOut */, AccountId: ctx.cash1Id,
CounterpartyId: s1.supplierId, CurrencyId: ctx.currencyId, Amount: partial, CounterpartyId: s1.supplierId, CurrencyId: ctx.currencyId, Amount: partial,
Purpose: 'Оплата приёмки №1 (частично)', Links: [{ SupplyId: s1.id, LinkedSum: partial }], Purpose: 'Оплата приёмки №1 (частично)', Links: [{ SupplyId: s1.id, LinkedSum: partial }],
}) })
let ok1 = p1.status < 400 && !!p1.data?.id let ok1 = p1.status < 400 && !!p1.data?.id
if (ok1) { const post = await api.post(`/api/money/transactions/${p1.data.id}/post`, {}); ok1 = post.status < 400; ctx.pay.push({ txId: p1.data.id, supplyIdx: 0, amount: partial, account: 'cash' }); s1.paid = partial } if (ok1) { const post = await api.post(`/api/money/transactions/${p1.data.id}/post`, {}); ok1 = post.status < 400; ctx.pay.push({ txId: p1.data.id, supplyIdx: 0, amount: partial, account: 'cash', accountId: ctx.cash1Id! }); s1.paid = partial }
rec(step, 'api', 'Оплата №1 частично наличными из кассы', ok1, '204', `${p1.status}`) rec(step, 'api', 'Оплата №1 частично наличными из кассы (≤ остатка)', ok1, '204', `${p1.status}`)
// Отказ при оплате больше остатка кассы (нет настройки «минус»).
const over = await api.post('/api/money/transactions', {
Date: new Date().toISOString(), Kind: 4 /* CashOut */, AccountId: ctx.cash1Id,
CounterpartyId: s1.supplierId, CurrencyId: ctx.currencyId, Amount: round2(ctx.cashOpening + 100_000),
Purpose: 'Переплата (тест отказа)', Links: [{ SupplyId: s1.id, LinkedSum: 0 }], ExpenseItemId: null,
})
if (over.data?.id) {
const overPost = await api.post(`/api/money/transactions/${over.data.id}/post`, {})
rec(step, 'api', 'Оплата больше остатка кассы → отказ (409 insufficient-funds)', overPost.status === 409, 409, overPost.status)
if (overPost.status !== 409) { await api.post(`/api/money/transactions/${over.data.id}/unpost`, {}); report.bug({ step: '08', severity: 'critical', title: 'Расход больше остатка кассы прошёл (касса в минус)', detail: `post=${overPost.status}` }) }
await api.delete(`/api/money/transactions/${over.data.id}`)
} else {
rec(step, 'api', 'Оплата больше остатка кассы → отказ', over.status === 409, 409, over.status)
}
// №2 — полностью с банковского счёта // №2 — полностью с банковского счёта
const p2 = await api.post('/api/money/transactions', { const p2 = await api.post('/api/money/transactions', {
@ -818,7 +850,7 @@ export async function step08_supplier_payments({ ctx, step, report }: StepCtx) {
Purpose: 'Оплата приёмки №2 (полностью)', Links: [{ SupplyId: s2.id, LinkedSum: s2.total }], Purpose: 'Оплата приёмки №2 (полностью)', Links: [{ SupplyId: s2.id, LinkedSum: s2.total }],
}) })
let ok2 = p2.status < 400 && !!p2.data?.id let ok2 = p2.status < 400 && !!p2.data?.id
if (ok2) { const post = await api.post(`/api/money/transactions/${p2.data.id}/post`, {}); ok2 = post.status < 400; ctx.pay.push({ txId: p2.data.id, supplyIdx: 1, amount: s2.total, account: 'bank' }); s2.paid = s2.total } if (ok2) { const post = await api.post(`/api/money/transactions/${p2.data.id}/post`, {}); ok2 = post.status < 400; ctx.pay.push({ txId: p2.data.id, supplyIdx: 1, amount: s2.total, account: 'bank', accountId: ctx.bankId! }); s2.paid = s2.total }
rec(step, 'api', 'Оплата №2 полностью с банковского счёта', ok2, '204', `${p2.status}`) rec(step, 'api', 'Оплата №2 полностью с банковского счёта', ok2, '204', `${p2.status}`)
// статусы оплаты (PayedSum) // статусы оплаты (PayedSum)
@ -926,6 +958,16 @@ export async function step09_cashier_shift({ ctx, step, report }: StepCtx) {
rec(step, 'api', 'Z-отчёт: нал.продажи и expectedCash сходятся', zOk, `${salesCash}/${expected}`, `${z.data?.sales?.cash}/${z.data?.expectedCash}`) rec(step, 'api', 'Z-отчёт: нал.продажи и expectedCash сходятся', zOk, `${salesCash}/${expected}`, `${z.data?.sales?.cash}/${z.data?.expectedCash}`)
ctx.shift = { id: shiftId, pointId: ctx.point1Id, cashId: ctx.cash1Id, openingFloat, salesCash, salesCard, returnsCash: returnCash, drawerIn, drawerOut, expected, actual: actualCash } ctx.shift = { id: shiftId, pointId: ctx.point1Id, cashId: ctx.cash1Id, openingFloat, salesCash, salesCard, returnsCash: returnCash, drawerIn, drawerOut, expected, actual: actualCash }
ctx.closedShifts.push({ cashAcctId: ctx.cash1Id!, actual: actualCash, opening: openingFloat, acqAcctId: ctx.acq1Id, salesCard, returnsCard: 0 })
// Движения смены попали в остаток кассы: касса выросла на (факт − размен).
const balAfter = await api.get('/api/money/reports/balances')
const cashBal = ((balAfter.data?.accounts ?? []) as { accountId: string; currentBalance: number }[]).find((a) => a.accountId === ctx.cash1Id)?.currentBalance ?? 0
const expCashAfter = round2(ctx.cashOpening - sum(ctx.pay.filter((p) => p.accountId === ctx.cash1Id).map((p) => p.amount)) + (actualCash - openingFloat))
rec(step, 'api', 'Деньги смены в остатке кассы (касса += факт − размен)', approxEq(cashBal, expCashAfter), expCashAfter, cashBal)
if (!approxEq(cashBal, expCashAfter)) report.bug({ step: '09', severity: 'critical', title: 'Движения смены не попали в остаток кассы', detail: `ожид=${expCashAfter} факт=${cashBal}` })
const acqBal = ((balAfter.data?.accounts ?? []) as { accountId: string; currentBalance: number }[]).find((a) => a.accountId === ctx.acq1Id)?.currentBalance ?? 0
rec(step, 'api', 'Продажи картой в остатке эквайринга', approxEq(acqBal, salesCard), salesCard, acqBal)
// продажа без смены → отказ (смена уже закрыта) // продажа без смены → отказ (смена уже закрыта)
const noShift = await makeSale(api, ctx, { productId: prods[1].id, qty: 1, unitPrice: prods[1].price, method: 0, pointId: ctx.point1Id }) const noShift = await makeSale(api, ctx, { productId: prods[1].id, qty: 1, unitPrice: prods[1].price, method: 0, pointId: ctx.point1Id })
@ -990,11 +1032,13 @@ export async function step09a_points_binding({ ctx, step, report }: StepCtx) {
const exp1 = round2(1000 + sp1.paidCash) const exp1 = round2(1000 + sp1.paidCash)
const c1 = await api.post(`/api/money/shifts/${shift1}/close`, { ActualCash: exp1, Notes: 't1' }) const c1 = await api.post(`/api/money/shifts/${shift1}/close`, { ActualCash: exp1, Notes: 't1' })
rec(step, 'api', 'Точка 1: касса = размен + её продажа (деньги не смешаны)', approxEq(Number(c1.data?.expectedCash), exp1), exp1, c1.data?.expectedCash) rec(step, 'api', 'Точка 1: касса = размен + её продажа (деньги не смешаны)', approxEq(Number(c1.data?.expectedCash), exp1), exp1, c1.data?.expectedCash)
ctx.closedShifts.push({ cashAcctId: ctx.cash1Id!, actual: exp1, opening: 1000, acqAcctId: ctx.acq1Id, salesCard: 0, returnsCard: 0 })
} }
if (shift2) { if (shift2) {
const exp2 = round2(2000 + sp2.paidCash) const exp2 = round2(2000 + sp2.paidCash)
const c2 = await api.post(`/api/money/shifts/${shift2}/close`, { ActualCash: exp2, Notes: 't2' }) const c2 = await api.post(`/api/money/shifts/${shift2}/close`, { ActualCash: exp2, Notes: 't2' })
rec(step, 'api', 'Точка 2: касса = размен + её продажа (деньги не смешаны)', approxEq(Number(c2.data?.expectedCash), exp2), exp2, c2.data?.expectedCash) rec(step, 'api', 'Точка 2: касса = размен + её продажа (деньги не смешаны)', approxEq(Number(c2.data?.expectedCash), exp2), exp2, c2.data?.expectedCash)
ctx.closedShifts.push({ cashAcctId: ctx.cash2Id!, actual: exp2, opening: 2000, acqAcctId: ctx.acq2Id, salesCard: 0, returnsCard: 0 })
} }
// продажа на НЕактивной точке → отказ // продажа на НЕактивной точке → отказ
@ -1145,13 +1189,16 @@ export async function step11_reports({ ctx, step, report }: StepCtx) {
// отчёт «Деньги»: остаток каждого счёта = начальный + движения // отчёт «Деньги»: остаток каждого счёта = начальный + движения
const bal = await api.get('/api/money/reports/balances') const bal = await api.get('/api/money/reports/balances')
const accs = (bal.data?.accounts ?? []) as { accountId: string; openingBalance: number; currentBalance: number }[] const accs = (bal.data?.accounts ?? []) as { accountId: string; openingBalance: number; currentBalance: number }[]
// касса: opening − оплата №1 наличными (partial); банк: opening − оплата №2 // остаток счёта = начальный + платежи + ДВИЖЕНИЯ СМЕН (независимый расчёт).
const paidCash = ctx.pay.filter((p) => p.account === 'cash').reduce((a, p) => a + p.amount, 0)
const paidBank = ctx.pay.filter((p) => p.account === 'bank').reduce((a, p) => a + p.amount, 0)
const cashAcc = accs.find((a) => a.accountId === ctx.cash1Id) const cashAcc = accs.find((a) => a.accountId === ctx.cash1Id)
rec(step, 'api', 'Деньги: касса = начальный − оплата наличными', !!cashAcc && approxEq(cashAcc.currentBalance, round2(ctx.cashOpening - paidCash)), round2(ctx.cashOpening - paidCash), cashAcc?.currentBalance) const expCash = indepBalance(ctx, ctx.cash1Id, ctx.cashOpening)
rec(step, 'api', 'Деньги: касса = начальный − оплаты + движения смен', !!cashAcc && approxEq(cashAcc.currentBalance, expCash), expCash, cashAcc?.currentBalance)
const bankAcc = accs.find((a) => a.accountId === ctx.bankId) const bankAcc = accs.find((a) => a.accountId === ctx.bankId)
rec(step, 'api', 'Деньги: банк = начальный − оплата с банка', !!bankAcc && approxEq(bankAcc.currentBalance, round2(ctx.bankOpening - paidBank)), round2(ctx.bankOpening - paidBank), bankAcc?.currentBalance) const expBank = indepBalance(ctx, ctx.bankId, ctx.bankOpening)
rec(step, 'api', 'Деньги: банк = начальный − оплата с банка', !!bankAcc && approxEq(bankAcc.currentBalance, expBank), expBank, bankAcc?.currentBalance)
const acqAcc = accs.find((a) => a.accountId === ctx.acq1Id)
const expAcq = indepBalance(ctx, ctx.acq1Id, ctx.acqOpening)
rec(step, 'api', 'Деньги: эквайринг = продажи картой − возвраты картой', !!acqAcc && approxEq(acqAcc.currentBalance, expAcq), expAcq, acqAcc?.currentBalance)
// долги поставщикам // долги поставщикам
const debts = await api.get('/api/money/reports/supplier-debts?withDebtOnly=true') const debts = await api.get('/api/money/reports/supplier-debts?withDebtOnly=true')
@ -1194,16 +1241,22 @@ export async function step12_invariants({ ctx, step, report }: StepCtx) {
} }
if (!stockOk) report.bug({ step: '12', severity: 'critical', title: 'Остаток ≠ сумме движений склада', detail: 'см. строки шага' }) if (!stockOk) report.bug({ step: '12', severity: 'critical', title: 'Остаток ≠ сумме движений склада', detail: 'см. строки шага' })
// 2) по каждому счёту: баланс ≡ начальный + Σ проведённых операций // 2) по каждому счёту: остаток ≡ НЕЗАВИСИМО посчитанной в тесте сумме
// (начальный + платежи + движения смен), а НЕ сумме из того же API.
const bal = await api.get('/api/money/reports/balances') const bal = await api.get('/api/money/reports/balances')
const accs = (bal.data?.accounts ?? []) as { accountId: string; name: string; openingBalance: number; currentBalance: number }[] const accs = (bal.data?.accounts ?? []) as { accountId: string; name: string; openingBalance: number; currentBalance: number }[]
for (const a of accs) { const known: [string | undefined, number, string][] = [
const txs = await api.get(`/api/money/transactions?accountId=${a.accountId}&status=1&pageSize=500`) [ctx.cash1Id, ctx.cashOpening, 'Касса'],
const items = (txs.data?.items ?? []) as { kind: number; amount: number }[] [ctx.bankId, ctx.bankOpening, 'Банк'],
const inSum = sum(items.filter((t) => [1, 3, 6].includes(t.kind)).map((t) => Number(t.amount))) [ctx.acq1Id, ctx.acqOpening, 'Эквайринг'],
const outSum = sum(items.filter((t) => [2, 4, 5].includes(t.kind)).map((t) => Number(t.amount))) [ctx.cash2Id, 3000, 'Касса-2'],
const expected = round2(Number(a.openingBalance) + inSum - outSum) ]
rec(step, 'api', `Инвариант счёт≡начальный+Σопераций: ${a.name.slice(0, 16)}`, approxEq(Number(a.currentBalance), expected), expected, a.currentBalance) for (const [acctId, opening, label] of known) {
if (!acctId) continue
const a = accs.find((x) => x.accountId === acctId)
const expected = indepBalance(ctx, acctId, opening)
rec(step, 'api', `Инвариант ${label} ≡ независимому расчёту (нач+платежи+смены)`, !!a && approxEq(Number(a.currentBalance), expected), expected, a?.currentBalance)
if (a && !approxEq(Number(a.currentBalance), expected)) report.bug({ step: '12', severity: 'critical', title: `Остаток счёта ${label} ≠ независимой формуле`, detail: `ожид=${expected} факт=${a.currentBalance}` })
} }
// 3) долг поставщика ≡ формуле (пересчёт независимо) // 3) долг поставщика ≡ формуле (пересчёт независимо)

View file

@ -96,7 +96,7 @@ public async Task Partial_then_full_payment_moves_supply_status_unpaid_partial_p
var refs = await api.LoadRefsAsync(); var refs = await api.LoadRefsAsync();
var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}"); var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}");
var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 150m, RandomBarcode()); var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 150m, RandomBarcode());
var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 0m); var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 1_000m);
var (supplyId, total) = await api.CreatePostedSupplyAsync(refs, supplier, product, qty: 10m, unitPrice: 100m); var (supplyId, total) = await api.CreatePostedSupplyAsync(refs, supplier, product, qty: 10m, unitPrice: 100m);
total.Should().Be(1000m); total.Should().Be(1000m);
@ -122,7 +122,7 @@ public async Task Overpaying_supply_is_rejected()
var refs = await api.LoadRefsAsync(); var refs = await api.LoadRefsAsync();
var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}"); var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}");
var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 150m, RandomBarcode()); var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 150m, RandomBarcode());
var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 0m); var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 900m);
var (supplyId, _) = await api.CreatePostedSupplyAsync(refs, supplier, product, 5m, 100m); // total 500 var (supplyId, _) = await api.CreatePostedSupplyAsync(refs, supplier, product, 5m, 100m); // total 500
// Платёж на 800 с привязкой 800 → при проведении переплата → 409. // Платёж на 800 с привязкой 800 → при проведении переплата → 409.
@ -146,7 +146,7 @@ public async Task Supplier_debt_report_reflects_supplies_minus_payments()
var refs = await api.LoadRefsAsync(); var refs = await api.LoadRefsAsync();
var supplier = await api.CreateCounterpartyAsync($"Долговой-{Guid.NewGuid():N}"); var supplier = await api.CreateCounterpartyAsync($"Долговой-{Guid.NewGuid():N}");
var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 150m, RandomBarcode()); var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 150m, RandomBarcode());
var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 0m); var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 1_000m);
var (supplyId, _) = await api.CreatePostedSupplyAsync(refs, supplier, product, 10m, 100m); // 1000 var (supplyId, _) = await api.CreatePostedSupplyAsync(refs, supplier, product, 10m, 100m); // 1000
await PaySupplyAsync(api, cash, refs.CurrencyId, supplyId, 300m); await PaySupplyAsync(api, cash, refs.CurrencyId, supplyId, 300m);
@ -211,6 +211,51 @@ public async Task Closing_shift_computes_expected_cash_and_difference()
body.GetProperty("difference").GetDecimal().Should().Be(0m); body.GetProperty("difference").GetDecimal().Should().Be(0m);
} }
[Fact]
public async Task Closing_shift_posts_cash_sales_into_cash_account_balance()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"shbal-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}");
var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 500m, RandomBarcode());
await api.CreatePostedSupplyAsync(refs, supplier, product, 10m, 100m);
var retailPoint = await api.CreateRetailPointAsync(refs.StoreId, "Т1");
var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 0m);
var shiftId = await OpenShiftAsync(api, retailPoint, cash, openingFloat: 5_000m);
var saleId = await CreateCashSaleDraftAsync(api, refs, retailPoint, product, 2m, 500m); // 1000 нал
(await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { })).EnsureSuccessStatusCode();
(await api.Http.PostAsJsonAsync($"/api/money/shifts/{shiftId}/close", new { actualCash = 6_000m, notes = (string?)null })).EnsureSuccessStatusCode();
// Остаток кассы = 0 (нач. счёта) + (6000 факт − 5000 размен) = 1000 (продажа нал).
var balances = await api.GetJsonAsync("/api/money/reports/balances");
var row = balances.GetProperty("accounts").EnumerateArray().First(x => x.GetProperty("accountId").GetString() == cash);
row.GetProperty("currentBalance").GetDecimal().Should().Be(1_000m);
}
[Fact]
public async Task Cash_expense_over_balance_is_rejected()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"neg-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 5_000m);
var items = await api.ListAsync("/api/money/expense-items?pageSize=50");
var expenseItemId = items.First().GetProperty("id").GetString();
var created = await api.Http.PostAsJsonAsync("/api/money/transactions", new
{
date = DateTime.UtcNow, kind = 4, accountId = cash, currencyId = refs.CurrencyId,
amount = 10_000m, expenseItemId, purpose = "big",
});
created.EnsureSuccessStatusCode();
var txId = (await created.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
// 10 000 > остаток 5 000 → 409 (орг не разрешает минус).
var post = await api.Http.PostAsJsonAsync($"/api/money/transactions/{txId}/post", new { });
((int)post.StatusCode).Should().Be(409);
}
// ── helpers ───────────────────────────────────────────────────────────── // ── helpers ─────────────────────────────────────────────────────────────
private static async Task PostTxAsync(ApiActor api, int kind, string accountId, decimal amount, string currencyId, private static async Task PostTxAsync(ApiActor api, int kind, string accountId, decimal amount, string currencyId,
bool needExpenseItem = false, ApiActor? api2 = null) bool needExpenseItem = false, ApiActor? api2 = null)

View file

@ -117,4 +117,18 @@ public void RolePermissions_default_denies_money()
p.MoneyEdit.Should().BeFalse(); p.MoneyEdit.Should().BeFalse();
p.CashShiftOperate.Should().BeFalse(); p.CashShiftOperate.Should().BeFalse();
} }
[Fact]
public void Shift_net_cash_movement_equals_actual_minus_opening()
{
// Вклад смены в кассу = продажи нал − возвраты нал + внесения − выплаты +
// расхождение = (expected − размен) + (факт − expected) = факт − размен.
// Числа — из факта стенда (смена С-2026-000001).
decimal opening = 5_000m, salesCash = 3_775m, returnsCash = 1_500m, drawerIn = 10_000m, drawerOut = 3_000m;
var expected = MoneyMath.ExpectedCash(opening, salesCash, returnsCash, drawerIn, drawerOut);
decimal actual = 14_775m;
var diff = MoneyMath.CashDifference(actual, expected);
var net = salesCash - returnsCash + drawerIn - drawerOut + diff;
net.Should().Be(actual - opening);
}
} }