food-market/src/food-market.domain/Organizations/Organization.cs
nns e39e4fe1d5
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
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
feat(registry+pricing): «Реестр товаров» + схема цен (закупочная/себестоимость/продажная)
РЕЕСТР ТОВАРОВ (глобальный системный шаблон карточек):
- Домен ProductRegistry{Group,Card,Barcode} (Entity, без OrganizationId),
  миграция Phase47; штрихкод уникален в реестре (ключ новизны).
- Наполнение «из МойСклад заново» (команда суперадмина, повторяемая): читает
  entity/product (архивные/модификации/комплекты/услуги — считает и пропускает),
  группы из productFolder (путь), штрихкоды всех типов; buyPrice→закупочная+
  себестоимость, salePrices→продажная; пересоздаёт только MoySklad-карточки,
  Shop-карточки не трогает. МойСклад только чтение (пагинация 1000, 429-бэкофф).
- Фоновое пополнение из каталогов магазинов-участников (recurring, флаг в
  PlatformSettings, по умолчанию ВЫКЛ): новые штрихкоды → карточки; фильтр
  мусора (неверная контр. цифра EAN-13/8, пустое имя); существующие не трогает.
- Копия реестра в каталог нового магазина при регистрации — фоновым заданием
  (Hangfire) сразу после signup; баннер «Каталог загружается…», повторно не
  копирует. Остатков нет (0).
- Флаг организации ContributesToRegistry (демо/автотест — ВЫКЛ миграцией).
- SuperAdmin раздел «Реестр товаров»: поиск/правка/удаление карточек, тоггл
  фонового пополнения (причина+аудит), кнопка наполнения из МойСклад, флаги
  участия орг. Только SuperAdmin.

СХЕМА ЦЕН (решение владельца 12.10): три цены в карточке.
- Закупочная: ReferencePrice→PurchasePrice (миграция Phase48 RENAME COLUMN,
  данные сохранены). Каждая проведённая приёмка пишет закупочную = цена строки;
  unpost возвращает из предыдущей проведённой приёмки. API отдаёт purchasePrice
  и временно referencePrice (алиас для iOS).
- Себестоимость (Cost): только чтение, скользящее среднее (как было).
- Продажная (бывш. «розничная»): меняется приёмкой только при ручной правке в
  строке. Системный тип цены «Розничная цена»→«Продажная цена» (миграция+сидер),
  подписи в UI переименованы.
- Убраны: автозаполнение эталонной при первой приёмке, ReferencePriceRefreshJob
  (30-дневная перезапись), понятие «эталонная» в UI.

Тесты: SupplyPricingSchemeTests (приёмка без правок → продажная не меняется,
закупочная = цена строки, себестоимость средняя; правка продажной в строке;
unpost → закупочная вернулась) — 3/3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 19:19:47 +05:00

202 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Organizations;
public class Organization : Entity
{
public string Name { get; set; } = null!;
public string CountryCode { get; set; } = "KZ";
public string? Bin { get; set; }
public string? Address { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public bool IsActive { get; set; } = true;
/// <summary>Архивирована ли организация. Архивные не видны пользователям
/// но данные сохраняются. Из архива можно восстановить или (после 30 дней)
/// удалить навсегда — этим управляет SuperAdmin.</summary>
public bool IsArchived { get; set; }
public DateTime? ArchivedAt { get; set; }
/// <summary>Account owner (главный владелец, не путать с админами роли).
/// Это конкретный AppUser, который считается «хозяином» организации;
/// SuperAdmin может сменить через отдельный action c reason в audit-log.</summary>
public Guid? AccountOwnerUserId { get; set; }
/// <summary>Персональный API-токен MoySklad. Храним per-organization чтобы
/// пользователю не нужно было вводить его каждый раз при импорте.</summary>
public string? MoySkladToken { get; set; }
/// <summary>Chat ID владельца org в Telegram. Привязка делается через
/// deep-link на бота `food-market` — бот шлёт код подтверждения в
/// telegram, юзер вводит код в OrganizationSettings, мы сохраняем
/// chatId. По этому ChatId Hangfire-джоб OwnerDailySummaryJob шлёт
/// ежедневную сводку (выручка вчера, топ-3, low-stock).
/// null — привязки нет; ноль допустимым не считаем.</summary>
public long? OwnerTelegramChatId { get; set; }
/// <summary>Валюта организации по умолчанию. Если MultiCurrencyEnabled=false,
/// в UI выбор валюты скрыт — всё в этой валюте.</summary>
public Guid? DefaultCurrencyId { get; set; }
public Currency? DefaultCurrency { get; set; }
/// <summary>Разрешены ли продажи/закупки в нескольких валютах. По умолчанию
/// false — тогда UI не предлагает выбор валюты, всё в DefaultCurrency.</summary>
public bool MultiCurrencyEnabled { get; set; }
/// <summary>Показывать ли пользователю галку «В том числе НДС» на форме товара.
/// Если false (по умолчанию) — магазин работает с одной ставкой НДС и галка
/// скрыта, все товары считаются с НДС. Если true — можно для отдельных товаров
/// (хлеб, медикаменты) снимать галку.</summary>
public bool ShowVatEnabledOnProduct { get; set; }
/// <summary>Показывать ли на форме товара и в фильтрах галку «Услуга».
/// Большинство магазинов продают только физические товары — флаг выключен
/// по умолчанию, чтобы не захламлять UI.</summary>
public bool ShowServiceOnProduct { get; set; }
/// <summary>Показывать ли на форме товара и в фильтрах галку «Маркируемый».
/// Маркировка требуется только в нишевых категориях (алкоголь, лекарства,
/// табак) — по умолчанию выключено.</summary>
public bool ShowMarkedOnProduct { get; set; }
/// <summary>Показывать ли поля «Минимальный остаток» / «Максимальный остаток»
/// на карточке товара и одноимённую колонку в списке. Нужно в основном
/// торговым сетям со свободным местом на полке — по умолчанию выключено.</summary>
public bool ShowMinMaxStock { get; set; }
/// <summary>Разрешить ли цены с дробной частью (две цифры после запятой).
/// По умолчанию false — большинству KZ-магазинов хватает круглых тенге;
/// если включено, MoneyInput работает с шагом 0.01 и форматирует с .00,
/// иначе шаг 1 и значения округляются до целого даже при попытке прислать
/// дробное через API.</summary>
public bool AllowFractionalPrices { get; set; }
/// <summary>Разрешить ли уходить счетам/кассам в минус. По умолчанию выкл.:
/// расход больше остатка (оплата, выплата из смены) отклоняется с 409.
/// Менять может только администратор организации.</summary>
public bool AllowNegativeBalance { get; set; }
/// <summary>Показывать ли в карточке товара поле «Эталонная цена».
/// Default: true.</summary>
public bool ShowReferencePriceOnProduct { get; set; } = true;
/// <summary>Показывать ли в карточке товара поле «Страна происхождения».
/// Default: false. Большинству KZ-магазинов это поле не нужно;
/// включать для торговцев импортом или маркируемой продукцией.</summary>
public bool ShowCountryOfOriginOnProduct { get; set; }
/// <summary>Показывать ли в карточке товара поле «Описание». Default:
/// false. Описания ведут единицы магазинов; обычно текстовая колонка
/// просто захламляет карточку.</summary>
public bool ShowDescriptionOnProduct { get; set; }
/// <summary>Расширенный режим для организации. По умолчанию <c>false</c>
/// — виден только «простой» набор функций (базовый розничный учёт).
/// Включить может только SuperAdmin через
/// <c>PUT /api/superadmin/organizations/{id}/advanced-mode</c>; в
/// настройках магазина переключателя нет. При выключении данные
/// скрытых модулей не удаляются; при повторном включении всё видно.
/// Серверная защита — атрибут <c>[RequiresAdvancedMode]</c> на
/// контроллерах скрытых модулей.</summary>
public bool IsAdvancedMode { get; set; }
// ─ Весовые этикетки (внутримагазинные EAN-13, как в МойСкладе) ─────
/// <summary>Разбирать ли весовые этикетки (EAN-13 с префиксом из списков).
/// Default FALSE (у владельца весов с печатью этикеток нет; штрихкоды весовых —
/// обычные коды в карточке). Включается явно; разбор применяется только после
/// проверки точного совпадения штрихкода.</summary>
public bool WeightBarcodeEnabled { get; set; } = false;
/// <summary>Префиксы (2 цифры) весовых этикеток, где зашит ВЕС. CSV, напр.
/// «20,21,22,23,24». null → дефолт 20–24.</summary>
public string? WeightBarcodeWeightPrefixes { get; set; }
/// <summary>Префиксы весовых этикеток, где зашита ЦЕНА. CSV, напр.
/// «25,26,27,28,29». null → дефолт 25–29.</summary>
public string? WeightBarcodePricePrefixes { get; set; }
/// <summary>Длина кода товара (PLU) внутри весовой этикетки. Default 5.</summary>
public int WeightBarcodeCodeDigits { get; set; } = 5;
/// <summary>Формат кода, который генерируется для НОВОГО весового товара:
/// «ean13» — внутренний EAN-13 (как у штучных), «short» — короткий код
/// «&lt;префикс&gt; + N цифр» (у владельца: 27 + 5 цифр). Default «short».</summary>
public string WeightedCodeFormat { get; set; } = "short";
/// <summary>Префикс короткого кода весового товара (2 цифры). Default 27 (как у
/// владельца). Следующий свободный N-значный номер добавляется автоматически.</summary>
public int WeightedCodePrefix { get; set; } = 27;
/// <summary>Сколько цифр в номере после префикса короткого кода весового товара.
/// Default 5 (итого 7 знаков: «27xxxxx»).</summary>
public int WeightedCodeDigits { get; set; } = 5;
// ─ Уведомления об отрицательных остатках (идея владельца 08.10) ────────
/// <summary>Слать ли уведомления «товары ушли в минус» (центр уведомлений,
/// Telegram, push). Default true. Выключение — только администратор.</summary>
public bool NegStockNotifyEnabled { get; set; } = true;
/// <summary>Агрегация: не чаще одного уведомления на склад раз в N минут.
/// Default 15. Защита от спама при серии продаж в минус.</summary>
public int NegStockNotifyIntervalMinutes { get; set; } = 15;
/// <summary>Тихие часы — час начала (0–23, время Asia/Almaty, UTC+5).
/// В интервале [From,To) уведомления копятся, но не шлются. null — тихих
/// часов нет. Если From==To — считается, что тихих часов нет.</summary>
public int? NegStockQuietFromHour { get; set; }
/// <summary>Тихие часы — час конца (0–23). Интервал может переходить через
/// полночь (напр. From=22, To=8).</summary>
public int? NegStockQuietToHour { get; set; }
/// <summary>Эскалация: если минус не закрыт за N часов — повторное
/// уведомление владельцу/админам + блок «Просроченные минусы». Default 24.</summary>
public int NegStockEscalateHours { get; set; } = 24;
/// <summary>Отключённые проверки «Проверки данных» (CSV кодов: no-group,
/// no-sale-price, no-barcode, dup-barcode, dup-name-unit). null/пусто — все включены.</summary>
public string? DataQualityDisabledChecks { get; set; }
// ─ Sprint 11: ОФД (фискализация чеков) ────────────────────────────
// Каждая организация выбирает СВОЕГО ОФД-оператора. Креды per-tenant:
// у одной фирмы может быть Webkassa, у другой — Касса24. ApiKey/Secret
// шифруются через DataProtection (purpose=`foodmarket.fiscal`) — в
// открытом виде в API-ответах не возвращаются (только has-* флаги).
/// <summary>Тип ОФД-провайдера. 0=None (не фискализировать — дефолт),
/// 1=Mock, 2=Webkassa, 3=Kassa24, 4=OfdSolo. Значения — см.
/// <c>FiscalProviderKind</c> в application-слое.</summary>
public int FiscalProvider { get; set; }
/// <summary>Зашифрованный ApiKey оператора (base64 через DataProtection,
/// purpose=`foodmarket.fiscal`). Никогда не отдаётся в API-ответах.</summary>
public string? FiscalApiKeyEncrypted { get; set; }
/// <summary>Зашифрованный ApiSecret оператора (если требуется — у
/// Webkassa, например, токен-логин, у Касса24 — отдельный secret).</summary>
public string? FiscalApiSecretEncrypted { get; set; }
/// <summary>Уникальный номер кассы у оператора. У Webkassa называется
/// <c>CashboxUniqueNumber</c>, передаётся в каждом чеке.</summary>
public string? FiscalCashboxUniqueNumber { get; set; }
/// <summary>Опциональный override URL'а оператора (для тестового
/// контура / sandbox'а). null → используется production URL из
/// реализации провайдера.</summary>
public string? FiscalApiBaseUrl { get; set; }
// ─ Реестр товаров ─────────────────────────────────────────────────────────
/// <summary>Пополняет ли каталог этого магазина общий «Реестр товаров»
/// (фоновое задание берёт новые штрихкоды в реестр). По умолчанию true для
/// зарегистрированных магазинов; демо/автотестовые выключаются.</summary>
public bool ContributesToRegistry { get; set; } = true;
/// <summary>Статус копии реестра в каталог магазина при регистрации:
/// 0=не было, 1=идёт загрузка, 2=готово, 3=ошибка. Пока 1 — в админке
/// показывается «Каталог из реестра загружается…». Повторно не копируется.</summary>
public int RegistryCopyState { get; set; }
public DateTime? RegistryCopiedAt { get; set; }
}