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
РЕЕСТР ТОВАРОВ (глобальный системный шаблон карточек):
- Домен 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>
202 lines
15 KiB
C#
202 lines
15 KiB
C#
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» — короткий код
|
||
/// «<префикс> + 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; }
|
||
}
|