From 495f0aabeecbf46bb59ace65c6602284a00e3d8b Mon Sep 17 00:00:00 2001 From: nurdotnet <278048682+nurdotnet@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:57:06 +0500 Subject: [PATCH] docs: audit of our domain entities vs. live MoySklad API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-checked every entity (Product, Counterparty, Supply, RetailSale, Stock, Store, RetailPoint, Organization, ProductGroup, Barcode, Price, PriceType, Country, Currency, VatRate, UoM) against real responses from MoySklad's API — a flat list of: - fields we have and MS doesn't (to justify or drop) - fields MS has and we don't (to add) - semantic mismatches (e.g. MS holds prices in kopecks, our decimal) Report only, no code changes — to be discussed with the user before touching models/migrations. Priorities are split into P1 (import parity: ExternalCode, Code, TrackingType enum, PaymentItemType, KZ entrepreneur type), P2 (semantic fixes: RetailSale payment sums, Overhead on supply, legal fields on Organization), P3 (nice-to-have), and a list of deliberate divergences (why our VatRate/StockMovement exist even though MS doesn't model them that way). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/audit-moysklad.md | 464 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 464 insertions(+) create mode 100644 docs/audit-moysklad.md diff --git a/docs/audit-moysklad.md b/docs/audit-moysklad.md new file mode 100644 index 0000000..b04ccc9 --- /dev/null +++ b/docs/audit-moysklad.md @@ -0,0 +1,464 @@ +# Аудит наших доменных сущностей vs. MoySklad API + +Источник правды — живой MoySklad API `/api/remap/1.2/`. Проверялись ключи на реальных ответах (`?limit=2` на нашем аккаунте). Цель: каждая наша сущность должна либо повторять MoySklad, либо иметь явно оправданное отличие. Никаких «выдуманных» полей. + +Условные обозначения: +- **⛔** — у нас есть поле, которого нет у MoySklad → либо оправдать комментарием, либо удалить. +- **➕** — у MoySklad есть поле, которого нет у нас → потенциально добавить. +- **⚠️** — важный нюанс (тип, семантика, обязательность). + +--- + +## Counterparty → `entity/counterparty` + +Ключи MoySklad (из ответа API, верхний уровень): `accountId, accounts, archived, bonusPoints, bonusProgram, companyType, created, externalCode, files, group, id, meta, name, notes, owner, salesAmount, shared, state, tags, updated` + расширяемые: `legalTitle, legalAddress, inn, kpp, ogrn, ogrnip, certificateNumber, certificateDate, phone, email, actualAddress, description, discountCardNumber, priceType, sex, salesChannel`. + +| Наше поле | MoySklad | Комментарий | +|---|---|---| +| `Name` | `name` | ОК | +| `LegalName` | `legalTitle` | rename? или доп. комментарий-алиас | +| `Kind` (CounterpartyKind) | **нет** | ⛔ уже исправили enum (`Unspecified/Supplier/Customer/Both`), но MoySklad не имеет этого поля вообще — он различает контрагентов через `tags` или через `state` (статус в пайплайне продаж/закупок). **TODO:** либо оставить Kind только как UI-фильтр (не импортировать из MoySklad), либо перейти на теги | +| `Type` (LegalEntity/Individual) | `companyType` | ⚠️ у MoySklad 3 значения: `legal`, `individual`, `entrepreneur` (ИП!). У нас ИП отсутствует — **добавить `IndividualEntrepreneur` в enum** (для РК актуально) | +| `Bin` (БИН, РК) | `inn` (12-значный БИН пишется туда) | ⚠️ MoySklad для всех рынков использует `inn` — 12 цифр это ИИН РФ, 12 цифр РК — БИН. Мы вынесли `Bin` отдельно, при импорте MoySklad кладёт в `inn`. **TODO:** документировать маппинг Bin ↔ inn | +| `Iin` (ИИН, РК) | `inn` (тот же) | ⚠️ same — MoySklad не различает | +| `TaxNumber` | `inn` | дубль | +| `CountryId` | `country` (extended, по `meta`) | ⚠️ MoySklad не на верхнем уровне — тянется при `?expand=country` | +| `Address` | `actualAddress` | ОК | +| `Phone` | `phone` | ОК | +| `Email` | `email` | ОК | +| `BankName, BankAccount, Bik` | `accounts` (массив объектов) | ⚠️ у MoySklad это **коллекция счетов** (до нескольких банков). У нас одиночные поля — **либо сделать коллекцию Accounts, либо документировать "берём первый"** | +| `ContactPerson` | `contactpersons` (sub-endpoint) | ⚠️ у MoySklad это отдельный endpoint `counterparty/{id}/contactpersons` — массив. У нас скалярное поле | +| `Notes` | `description` (или `notes` разные в разных версиях API?) | ⚠️ в ответе API было `notes` — ОК | +| `IsActive` | `archived` (inverse) | ОК | +| — | `tags` (массив) | ➕ **добавить** — удобно для классификации (в том числе заменой Kind) | +| — | `state` (ссылка на состояние в пайплайне) | ➕ отложить до Phase N (CRM) | +| — | `bonusPoints, bonusProgram, discountCardNumber` | ➕ отложить до дисконтных карт | +| — | `salesAmount` (вычисляемое) | не храним | +| — | `priceType` (персональный тип цены) | ➕ полезно для опта; добавить `Guid? DefaultPriceTypeId` | + +**TODO:** +1. Enum `CounterpartyType`: добавить `IndividualEntrepreneur = 3`. +2. Коллекция `CounterpartyAccount` (BankName/BankAccount/Bik/IsDefault) — или явный комментарий «храним только основной». +3. Коллекция `CounterpartyTag` (string) — для классификации при импорте из MoySklad. +4. Поле `DefaultPriceTypeId` → `PriceType` (для опта/персональной цены). +5. Комментарий на `Bin/Iin/TaxNumber`: при импорте из MoySklad все три могут прилететь из одного поля `inn` — логика различения по длине (12 цифр РК-формат) / по companyType. + +--- + +## Organization → `entity/organization` + +Ключи MS: `accountId, accounts, archived, bonusPoints, bonusProgram, companyType, companyVat__ru, created, email, externalCode, group, id, isEgaisEnable, meta, name, owner, payerVat, shared, updated` + extended: `legalTitle, legalAddress, actualAddress, inn, kpp, ogrn, ogrnip, okpo, director, chiefAccountant, phone, fax, utmUrl`. + +| Наше | MS | Комментарий | +|---|---|---| +| `Name` | `name` | ОК | +| `CountryCode` | **нет** | ⛔ у MoySklad нет — у них multi-tenant через account. У нас — multi-tenant через Organization, но CountryCode неочевиден. Оставить как есть, документировать почему (нам нужно для налоговых/локальных настроек) | +| `Bin` | `inn` | то же что и Counterparty | +| `Address` | `actualAddress` | ОК | +| `Phone` | `phone` | ОК | +| `Email` | `email` | ОК | +| `IsActive` | `archived` inverse | ОК | +| — | `legalTitle, legalAddress` | ➕ для офиц. документов | +| — | `kpp, ogrn, ogrnip, okpo` | РФ-специфично, пропускаем для РК | +| — | `payerVat` (bool, плательщик НДС) | ➕ полезно — есть ли НДС у нашей организации | +| — | `director, chiefAccountant` | ➕ для подписей на накладных | +| — | `accounts` (банковские) | ➕ аналогично Counterparty | +| — | `isEgaisEnable` | РФ, пропускаем | + +**TODO:** +1. `LegalName`, `LegalAddress`, `PayerVat` (bool), `DirectorName`, `ChiefAccountantName` — для накладных/счетов. +2. `CountryCode` оставить + `` комментарий почему у нас есть, а у MS нет. + +--- + +## Product → `entity/product` + +Ключи MS: `accountId, archived, barcodes, buyPrice, code, discountProhibited, externalCode, files, group, id, images, isSerialTrackable, meta, minPrice, name, owner, pathName, paymentItemType, productFolder, salePrices, shared, supplier, trackingType, uom, updated, useParentVat, variantsCount, volume, weight` + optional: `article, country, description, effectiveVat, minPrice.currency, taxSystem, vat, tnved, syncId, modifications`. + +| Наше | MS | Комментарий | +|---|---|---| +| `Name` | `name` | ОК | +| `Article` | `article` | ОК | +| `Description` | `description` | ОК | +| `UnitOfMeasureId` | `uom.meta` | ОК | +| `VatRateId` | `vat` (число) + `useParentVat` | ⚠️ у MS НДС хранится как число (20, 10, 12, 0) прямо на товаре. **Мы отдельная сущность VatRate**. Обоснование: нам нужно хранить локализованные названия ("НДС 12%", "Без НДС"), is-default, и позволять разным организациям иметь разные ставки. НО — при импорте надо резолвить число в VatRate по organization_id | +| `ProductGroupId` | `productFolder.meta` | ОК | +| `DefaultSupplierId` | `supplier.meta` | ОК (у MS тоже одиночная ссылка) | +| `CountryOfOriginId` | `country.meta` | ОК | +| `IsService` | `paymentItemType` (одно из значений = "SERVICE") | ⚠️ у MS это enum с ~10 значений; у нас bool. **TODO:** либо enum, либо документировать что мы учитываем только IsService | +| `IsWeighed` | **нет** | ⛔ у MS этого нет; характеристика ритейла, нам нужно для касс с весами. **Оставить, документировать.** | +| `IsAlcohol` | `tnved` (класс товара) или через group | ⚠️ у MS через tnved-код или through type классификаторы. Наше bool — упрощение. **Оставить с комментарием.** | +| `IsMarked` | `trackingType` (enum: NOT_TRACKED, BEER_ALCOHOL, …) | ⚠️ У MS это enum из 10+ вариантов маркировки. Наш `IsMarked: bool` — потеря информации. **TODO:** заменить на enum `TrackingType` (NOT_TRACKED/TOBACCO/ALCOHOL/SHOES/MEDICINE/…) | +| `MinStock, MaxStock` | `minimumBalance` (число), `stock` (runtime) | ⚠️ у MS есть только `minimumBalance` (нижняя граница). MaxStock — наш | +| `PurchasePrice, PurchaseCurrencyId` | `buyPrice.value, buyPrice.currency.meta` | ОК (MS упаковывает в объект, мы разнесли — **одно и то же**) | +| `ImageUrl` | `images` (массив через sub-endpoint) | ⚠️ у MS images коллекция, у нас одна + отдельная ProductImage. ОК, двойная запись для UX | +| `IsActive` | `archived` inverse | ОК | +| `Prices` (collection) | `salePrices` (массив inline в MS) | ⚠️ у MS цены — **массив внутри товара**, у нас — отдельная таблица. Оба норм; просто маппинг при sync | +| `Barcodes` (collection) | `barcodes` (массив inline) | ОК | +| `Images` (collection) | `images` (sub-endpoint) | ОК | +| — | `code` | ➕ внутренний код (отличается от `article`). **Добавить `Code`** | +| — | `externalCode` | ➕ используется при импорте/ERP-интеграциях. **Добавить `ExternalCode`** (актуально для импорта из MoySklad, 1C) | +| — | `discountProhibited` | ➕ «запрет скидок» — полезно на кассе | +| — | `minPrice.value/currency` | ➕ минимальная отпускная цена. **Добавить `MinPrice` + `MinPriceCurrencyId`** | +| — | `paymentItemType` | ➕ для фискализации: «товар/услуга/работа/подарочная карта/…». **Добавить enum `PaymentItemType`** (нужно для 54-ФЗ / КZ fiscal receipts) | +| — | `tnved` | ➕ код ТН ВЭД для трансграничной торговли | +| — | `volume, weight` | ➕ для логистики (доставка) | +| — | `variantsCount` | runtime агрегат, не храним | +| — | `files` | ➕ вложения (паспорта качества, фото упаковки) — отложить | + +**TODO:** +1. Добавить `Code`, `ExternalCode` на Product. +2. Заменить `IsMarked` на enum `TrackingType`. +3. Добавить `MinPrice`, `MinPriceCurrencyId`. +4. Добавить enum `PaymentItemType` + поле. +5. Поля `Volume`, `Weight`, `DiscountProhibited`. +6. Запомнить маппинг: `useParentVat` → наследовать НДС от ProductGroup (у нас сейчас не реализовано, надо подумать). + +--- + +## ProductGroup → `entity/productfolder` + +Ключи MS: `accountId, archived, externalCode, group, id, meta, name, owner, pathName, shared, updated, useParentVat` + `vat, effectiveVat, productFolder` (родитель). + +| Наше | MS | Комментарий | +|---|---|---| +| `Name` | `name` | ОК | +| `ParentId` | `productFolder.meta` | ОК (MS использует то же имя для родителя что и для самой сущности) | +| `Path` | `pathName` | ОК | +| `SortOrder` | **нет** | ⛔ у MS нет сортировки групп. Оставить, это UX | +| `IsActive` | `archived` inverse | ОК | +| — | `externalCode` | ➕ для импорта | +| — | `vat, useParentVat` | ➕ ставка НДС по умолчанию для товаров группы | + +**TODO:** +1. Добавить `ExternalCode`. +2. Добавить `VatRateId?` + `UseParentVat: bool` (для наследования). + +--- + +## ProductBarcode → `product.barcodes[]` + +У MS barcode — объект внутри product: `{type: 'ean13'|'ean8'|'code128'|'upc'|'gtin', value: '...'}`. Отдельной сущности нет. + +| Наше | MS | Комментарий | +|---|---|---| +| `Code` | `value` | ОК | +| `Type` | `type` | ⚠️ MS использует строки ('ean13', 'gtin', …) — мы уже enum | +| `IsPrimary` | **нет** | ⛔ у MS нет — первый считается основным. **Оставить с комментарием — у нас явная пометка.** | + +OK, расхождений существенных нет. + +--- + +## ProductPrice → `product.salePrices[]` + +У MS цены — массив объектов в product: `{value, currency: {meta}, priceType: {meta}}`. Отдельной сущности нет. + +Наше — отдельная таблица. Это **нормализованный вариант** — оправдано если цен много и есть выборки по PriceType. **TODO:** маппинг при импорте — проитерировать salePrices и создать ProductPrice per PriceType. + +--- + +## PriceType → `entity/pricetype` + +Ключи MS (из context): `id, name, externalCode`. + +| Наше | MS | Комментарий | +|---|---|---| +| `Name` | `name` | ОК | +| `IsDefault` | **нет** | ⛔ у MS — default определяется порядком или отдельно в настройках аккаунта. **Оставить** | +| `IsRetail` | **нет** | ⛔ наш флаг «используется на кассе». **Оставить** | +| `SortOrder` | **нет** | ⛔ UX. **Оставить** | +| — | `externalCode` | ➕ для импорта | + +**TODO:** +1. `ExternalCode`. + +--- + +## Country → `entity/country` + +Ключи MS: `code, description, externalCode, id, meta, name, updated`. + +| Наше | MS | Комментарий | +|---|---|---| +| `Code` | `code` | ⚠️ у MS формат ISO3166-1 **alpha-2 или числовой** — у нас alpha-2 | +| `Name` | `name` | ОК | +| `SortOrder` | **нет** | ⛔ UX | +| — | `description` | ➕ | +| — | `externalCode` | ➕ | + +OK, мелочь. + +--- + +## Currency → `entity/currency` + +Ключи MS: `archived, code, default, fullName, id, indirect, isoCode, majorUnit, meta, minorUnit, multiplicity, name, rate, rateUpdateType, system`. + +| Наше | MS | Комментарий | +|---|---|---| +| `Code` | `isoCode` или `code` | ⚠️ у MS `isoCode` (строка "KZT") и `code` (цифровой "398") — у нас `Code` = строка ISO | +| `Name` | `name` | ОК | +| `Symbol` | **нет** | ⛔ у MS нет символа "₸" — но это UX. **Оставить** | +| `MinorUnit` | `minorUnit` | ОК | +| `IsActive` | `archived` inverse | ОК | +| — | `default` (валюта аккаунта) | ➕ | +| — | `rate, rateUpdateType` | ➕ курс к базовой валюте (при мульти-валютности) | +| — | `multiplicity, indirect` | конвертация; если не мульти-валютные — не надо | +| — | `fullName` | ➕ «Тенге Казахстана» vs «KZT» | + +**TODO:** +1. Добавить `IsDefault: bool` (ровно одна валюта = true per tenant, или глобально). +2. `Rate, RateUpdateType` + `FullName` — отложить до мульти-валютности. + +--- + +## VatRate — у MoySklad нет `entity/vatrate` + +⚠️ У MS **ставки НДС хранятся как числовое поле на товаре** (`vat`). Отдельной таблицы нет — набор значений {0, 5, 7, 10, 12, 18, 20} и «без НДС» встроен в систему. + +Наше `VatRate` — отдельная сущность. **Обоснование сохранить:** +1. Локализованное название ("НДС 12%", "Без НДС"). +2. IsDefault per organization. +3. Разные организации в разных налоговых режимах (с НДС / УСН). +4. При добавлении новой ставки (например, на случай гипотетического увеличения в РК) — не править перечисление в коде. + +Но: **следите**, чтобы у товара хранился `VatRateId`, а не отдельно `vat: decimal`. При импорте из MS мапим число в запись VatRate. + +**Комментарий в коде нужен** — явно сказать, что мы отклонились от MoySklad сознательно. + +--- + +## UnitOfMeasure → `entity/uom` + +Ключи MS: `code, description, externalCode, id, meta, name, updated`. + +| Наше | MS | Комментарий | +|---|---|---| +| `Code` (ОКЕИ) | `code` | ОК, MS использует ОКЕИ-коды (796, 166, 112) | +| `Symbol` | **нет** | ⛔ у MS только `name` ("штука"). Мы вынесли "шт" отдельно для коротких надписей на ценниках/кассовых чеках. **Оставить.** | +| `Name` | `name` | ОК | +| `DecimalPlaces` | **нет** | ⛔ у MS на уровне продукта (`variantsCount`?), а не UoM. Наш `DecimalPlaces` определяет можно ли дробные количества (0=штучный, 3=весовой). **Оставить — важно для UX касс.** | +| `IsBase` | **нет** | ⛔ наше «базовая единица организации». Мелочь, оставить | +| `IsActive` | `archived` inverse (у MS есть `archived` в uom? перепроверить) | ⚠️ в нашем ответе API archived не было — у MS uom этого поля может не быть, потому что единицы системные | +| — | `description` | ➕ | +| — | `externalCode` | ➕ | + +**TODO:** +1. `ExternalCode`. + +--- + +## Store → `entity/store` + +Ключи MS: `accountId, address, archived, externalCode, group, id, meta, name, owner, pathName, shared, slots, updated, zones`. + +| Наше | MS | Комментарий | +|---|---|---| +| `Name` | `name` | ОК | +| `Code` | `externalCode`? или отдельно? | ⚠️ у MS только `externalCode`. **Добавить ExternalCode или rename Code→ExternalCode** | +| `Kind` (Warehouse/RetailFloor) | **нет** | ⛔ у MS такого деления нет. Обоснование: нам нужно отличать «склад» от «торгового зала» для UI и настроек касс. **Оставить с комментарием** | +| `Address` | `address` | ОК | +| `Phone` | **нет** | ⛔ у MS нет. Оставить | +| `ManagerName` | **нет** | ⛔ у MS нет. Оставить | +| `IsMain` | **нет** (но можно проставить через default) | ⛔ Оставить | +| `IsActive` | `archived` inverse | ОК | +| — | `pathName` | ➕ (если будут иерархические склады) | +| — | `slots` (ячейки склада) | ➕ отложить | +| — | `zones` (зоны склада) | ➕ отложить | + +**TODO:** +1. `ExternalCode` (или переименовать Code → ExternalCode). + +--- + +## RetailPoint → `entity/retailstore` + +У MS это **«Точка продаж» / кассовое место**. Огромное количество полей (~60) — в основном фискальные настройки. + +| Наше | MS | Комментарий | +|---|---|---| +| `Name` | `name` | ОК | +| `Code` | `externalCode` | rename or add | +| `StoreId` | `store.meta` | ОК | +| `Address` | **нет** (возможно `organization.actualAddress`) | ⛔ адрес не у точки, а у организации/склада. Пересмотреть, куда класть | +| `Phone` | **нет** | ⛔ | +| `FiscalSerial` | **нет такого поля**; есть `fiscalType`, `fiscalMemoryNumber`?, `ofdEnabled` | ⚠️ у MS фискальные настройки множественные. У нас один скаляр — упрощение. **TODO:** уточнить по мере подключения ККМ | +| `FiscalRegNumber` | `ofdEnabled` + `ofdSettings` | same | +| `IsActive` | `active, archived` | MS различает active и archived — у нас только IsActive | +| — | `priceType.meta` | ➕ тип цены для этой точки — **важно** | +| — | `allowCustomPrice` | ➕ разрешить ручную цену на кассе | +| — | `allowCreateProducts` | ➕ создать товар прямо на кассе | +| — | `discountEnable, discountMaxPercent` | ➕ скидки на кассе | +| — | `cashiers` (коллекция) | ➕ кто может работать за кассой | +| — | `sellReserves` | ➕ продавать резерв | +| — | `receiptTemplate` | ➕ шаблон чека | +| — | `returnFromClosedShiftEnabled` | ➕ возврат из закрытой смены | +| — | `requiredBirthdate/Email/Phone/Fio/Sex/DiscountCardNumber` | ➕ обязательные поля при продаже | +| — | `markingSellingMode, marksCheckMode, sendMarksForCheck` | ➕ маркировка товаров | + +**TODO:** +1. Обязательно: `DefaultPriceTypeId` (ссылка на `PriceType`). +2. Настройки кассы (скоп Phase 3 — касса): `AllowCustomPrice`, `AllowCreateProducts`, `SellReserves`, `DiscountMaxPercent`, `RequireCustomer...` — добавлять по мере реализации POS. +3. Коллекция `RetailPointCashier` (user_id, может ли работать). + +--- + +## Supply → `entity/supply` + `supply/{id}/positions` + +Document keys: `accountId, agent, applicable, created, externalCode, files, group, id, meta, moment, name, organization, owner, payedSum, positions, printed, published, rate, shared, store, sum, updated, vatEnabled, vatIncluded, vatSum`. + +| Наше | MS | Комментарий | +|---|---|---| +| `Number` | `name` | ⚠️ у MS «номер документа» = `name`. У нас `Number` — семантически то же | +| `Date` | `moment` | ОК | +| `Status` (Draft/Posted) | `applicable` (bool) | ⚠️ у MS это bool «проведён или нет». У нас enum Draft/Posted → эквивалентно | +| `SupplierId` | `agent.meta` | ⚠️ у MS вместо `supplier` общее слово `agent` (контрагент) | +| `StoreId` | `store.meta` | ОК | +| `CurrencyId` | `rate.currency.meta` | ⚠️ MS упаковывает в rate объект с курсом | +| `SupplierInvoiceNumber` | **нет на верхнем уровне**; есть в `attributes` | ⛔ у MS через custom attributes. Оставить | +| `SupplierInvoiceDate` | same | same | +| `Notes` | `description` | rename или комментарий | +| `Total` | `sum` | ОК | +| `PostedAt` | `updated` (когда applicable ставится true) | ⚠️ у MS нет выделенного поля; мы отдельно фиксируем | +| `PostedByUserId` | `owner.meta` | условно | +| — | `vatEnabled` | ➕ | +| — | `vatIncluded` | ➕ НДС включён в цену | +| — | `vatSum` | ➕ суммарный НДС документа | +| — | `payedSum` | ➕ сколько оплачено | +| — | `organization.meta` | ⚠️ у MS документ привязан к организации. **У нас TenantEntity несёт OrganizationId — уже есть** | +| — | `printed, published` | ➕ распечатан/опубликован | +| — | `overhead` (доп.расходы) | ➕ доставка/таможня — **важно для фактической себестоимости** | + +**Supply.Positions (SupplyLine) → supply/{id}/positions:** + +Ключи MS: `accountId, assortment, discount, id, meta, overhead, price, quantity, vat, vatEnabled`. + +| Наше (SupplyLine) | MS position | Комментарий | +|---|---|---| +| `ProductId` | `assortment.meta` | ⚠️ у MS `assortment` = может быть product ИЛИ variant ИЛИ service ИЛИ bundle. Мы только продукт | +| `Quantity` | `quantity` | ОК | +| `UnitPrice` | `price` | ⚠️ у MS `price` — в копейках (integer `100 = 1.00`). У нас decimal. **Маппинг при импорте: делить на 100** | +| `LineTotal` | **нет** (вычисляется) | ⛔ у MS не хранится | +| `SortOrder` | **нет** | ⛔ наш UX | +| — | `discount` | ➕ строковая скидка | +| — | `vat` | ➕ ставка НДС на позицию | +| — | `vatEnabled` | ➕ | +| — | `overhead` | ➕ доля накладных (для себестоимости) | + +**TODO Supply:** +1. Поля: `VatEnabled`, `VatIncluded`, `VatSum`, `PayedSum`, `Overhead`. +2. Lines: `Discount` (decimal), `VatPercent` (snapshot, уже подобное есть в RetailSaleLine), `VatEnabled`. +3. Комментарий: MS `price` в копейках — при импорте делить. + +--- + +## RetailSale → `entity/retaildemand` + `retaildemand/{id}/positions` + +Document keys: огромный список, ключевое: `agent, applicable, cashSum, noCashSum, qrSum, prepaymentCashSum, prepaymentNoCashSum, prepaymentQrSum, advancePaymentSum, fiscal, retailShift, retailStore, store, positions, rate, sum, vatEnabled, vatIncluded, vatSum, name, moment, organization, syncId`. + +| Наше | MS | Комментарий | +|---|---|---| +| `Number` | `name` | ОК | +| `Date` | `moment` | ОК | +| `Status` | `applicable` | ⚠️ bool vs enum | +| `StoreId` | `store.meta` | ОК | +| `RetailPointId` | `retailStore.meta` | ОК | +| `CustomerId` | `agent.meta` | ОК (nullable если не знаем покупателя) | +| `CashierUserId` | **нет напрямую**; `retailShift` → cashier | ⚠️ | +| `CurrencyId` | `rate.currency.meta` | ОК | +| `Subtotal, DiscountTotal, Total` | `sum` (= Total) | ⚠️ MS **не хранит subtotal и discount total отдельно** — только total. Но цена в позиции уже после скидки? Нет — `positions[].discount` хранится, total = sum(price*qty - discount) | +| `Payment` (PaymentMethod enum) | **cashSum + noCashSum + qrSum** | ⚠️ MS — **не enum, а суммы по видам оплаты**. Т.е. при mixed-оплате можно часть наличными + часть картой. **Наш enum Payment + PaidCash + PaidCard — неполный.** TODO: добавить `PaidQr` + убрать enum в пользу «сколько чем заплачено» | +| `PaidCash` | `cashSum` | ⚠️ у MS в копейках | +| `PaidCard` | `noCashSum` | ⚠️ в копейках | +| `Notes` | `description` | ОК | +| `PostedAt` | — | наш | +| `PostedByUserId` | `owner.meta` | условно | +| — | `qrSum` | ➕ **добавить `PaidQr`** (QR-оплата актуальна для КZ) | +| — | `retailShift.meta` | ➕ кассовая смена (отложить) | +| — | `fiscal` | ➕ пробит ли фискально | +| — | `syncId` | ➕ идентификатор для офлайн-касс (при резинхроне) | +| — | `prepaymentCashSum/NoCashSum/QrSum, advancePaymentSum` | ➕ предоплаты | + +**RetailSale.Positions (RetailSaleLine) → retaildemand/{id}/positions:** + +Ключи: `accountId, assortment, discount, id, meta, price, quantity, vat, vatEnabled`. + +| Наше (RetailSaleLine) | MS | Комментарий | +|---|---|---| +| `ProductId` | `assortment.meta` | ОК | +| `Quantity` | `quantity` | ОК | +| `UnitPrice` | `price` | ⚠️ копейки | +| `Discount` | `discount` | ОК | +| `LineTotal` | вычисляется | наш | +| `VatPercent` | `vat` | ОК (snapshot) | +| `SortOrder` | — | наш UX | +| — | `vatEnabled` | ➕ | + +**TODO RetailSale:** +1. Добавить `PaidQr: decimal`. +2. **Убрать `PaymentMethod` enum** в пользу денормализованных `PaidCash, PaidCard, PaidQr, PaidBonus` + computed `Method` (если все кроме одного = 0 → Cash/Card/QR, иначе Mixed). Либо оставить enum + PayHint, но быть готовым к частичной оплате. +3. `VatEnabled, VatIncluded, VatSum` (сумма НДС на документ — вычисляется). +4. Комментарий: MS `price/cashSum/noCashSum` в копейках при импорте. + +--- + +## Stock → `report/stock/bystore` + +У MS **нет отдельной сущности "Stock"** — это **отчёт**. Ответ `report/stock/bystore` содержит: +```json +{ "meta": {...}, "stockByStore": [ { "name": "Склад №1", "meta": {...}, "stock": 10.0, "reserve": 2.0, "inTransit": 0.0, "quantity": 12.0 } ] } +``` +Т.е. по каждому (product, store) — stock (сколько есть), reserve (резерв), inTransit (в пути), quantity = stock+inTransit. + +У нас `Stock` — **материализованный агрегат** (Quantity, ReservedQuantity, computed Available). Это **технически наше решение**, не требование бизнеса. + +**TODO:** +1. Комментарий в Stock.cs: объяснить, что это материализация (у MS динамический репорт). +2. **Добавить `InTransit: decimal`** — товар в пути (между складами при перемещении). + +--- + +## StockMovement — у MoySklad такой сущности нет + +⚠️ MS **не хранит journal движений** в явном виде — остатки рассчитываются в реальном времени из документов (supply, retaildemand, loss, enter, move и т.д.). Поэтому на вопрос «почему на складе минус 5» MS возвращает историю документов, а не journal. + +Наше `StockMovement` — **явный immutable journal**. Обоснование: +1. Мгновенный ответ на «почему такой остаток» без пробегания по всем документам. +2. Атомарные корректировки при баг-фиксах миграций. +3. Упрощённая репликация в офлайн-кассы. + +Это **сознательное отклонение** от MS — должно быть задокументировано в коде и в `docs/`. **TODO:** комментарий в StockMovement.cs + упоминание в `CLAUDE.md`. + +--- + +## Свод по приоритетам + +### Приоритет 1 — базовая совместимость импорта (на этой неделе): +- Product: `Code`, `ExternalCode`, `TrackingType` (enum) вместо `IsMarked`, `MinPrice`/`MinPriceCurrencyId`, `PaymentItemType` (enum) +- Counterparty: `CounterpartyType.IndividualEntrepreneur`, `ExternalCode`, tags (коллекция) +- ProductGroup: `ExternalCode` +- PriceType: `ExternalCode` +- Country, Currency, UnitOfMeasure, Store: `ExternalCode` +- RetailPoint: `DefaultPriceTypeId` + +### Приоритет 2 — смысловые (следующая итерация): +- RetailSale: `PaidQr`, убрать enum PaymentMethod в пользу суммовых полей +- Supply: `Overhead`, `VatSum`, `VatEnabled`, `VatIncluded` +- Organization: `LegalName`, `LegalAddress`, `PayerVat`, `DirectorName` +- Product: `Volume, Weight, DiscountProhibited` +- Stock: `InTransit` + +### Приоритет 3 — при необходимости: +- Counterparty: коллекция `Account`, `DefaultPriceType` +- ProductGroup: `VatRateId?` + `UseParentVat` +- RetailPoint: кассовые настройки (allowCustomPrice, discountMaxPercent, cashiers...) +- Store: slots, zones + +### Сознательно не копируем MS: +- `CounterpartyKind` (Supplier/Customer/Both) — у нас enum, у MS теги. Оставляем для UX/фильтрации. +- `Store.Kind` (Warehouse vs RetailFloor) — у MS нет, нам нужно. +- `VatRate` как отдельная сущность — у MS число на товаре. У нас справочник ради локализации. +- `StockMovement` journal — у MS нет. Выбор архитектуры. +- `Product.IsWeighed` / `IsAlcohol` — упрощения под ритейл. +- `UnitOfMeasure.Symbol`, `DecimalPlaces` — UX.