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>
This commit is contained in:
nns 2026-10-08 03:11:10 +05:00
parent 005c4ac5bf
commit 0977c639cd
3 changed files with 141 additions and 253 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 ₸». Выплата из смены
сверяется с наличными, которые реально есть в кассе на этот момент.
Включить минус может только администратор: Настройки организации →
«Разрешить отрицательные остатки».