diff --git a/CLAUDE.md b/CLAUDE.md
index f8fc24e..0068116 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -21,6 +21,12 @@
- MariaDB используется как сервер синхронизации
- Background worker: периодический sync push+pull
+## Guardrails
+- Не возвращать в проект удалённые legacy-разделы: cron jobs, importer utility, admin pricing, alerts, stock import.
+- Runtime-конфиг читается из user state (`config.yaml`) или через `-config` / `QFS_CONFIG_PATH`; не хранить рабочий `config.yaml` в репозитории.
+- `config.example.yaml` остаётся единственным шаблоном конфигурации в репо.
+- Любые изменения в sync должны сохранять local-first поведение: локальные CRUD не блокируются из-за недоступности MariaDB.
+
## Key SQLite Data
- `connection_settings`
- `local_components`
diff --git a/dist/qfs-darwin-amd64 b/dist/qfs-darwin-amd64
new file mode 100755
index 0000000..6077c74
Binary files /dev/null and b/dist/qfs-darwin-amd64 differ
diff --git a/dist/qfs-darwin-arm64 b/dist/qfs-darwin-arm64
new file mode 100755
index 0000000..44f74a4
Binary files /dev/null and b/dist/qfs-darwin-arm64 differ
diff --git a/dist/qfs-linux-amd64 b/dist/qfs-linux-amd64
new file mode 100755
index 0000000..b050add
Binary files /dev/null and b/dist/qfs-linux-amd64 differ
diff --git a/dist/qfs-windows-amd64.exe b/dist/qfs-windows-amd64.exe
new file mode 100755
index 0000000..e111808
Binary files /dev/null and b/dist/qfs-windows-amd64.exe differ
diff --git a/internal/handlers/sync_readiness_test.go b/internal/handlers/sync_readiness_test.go
new file mode 100644
index 0000000..72fbe48
--- /dev/null
+++ b/internal/handlers/sync_readiness_test.go
@@ -0,0 +1,64 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "git.mchus.pro/mchus/quoteforge/internal/localdb"
+ syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
+ "github.com/gin-gonic/gin"
+)
+
+func TestSyncReadinessOfflineBlocked(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ dir := t.TempDir()
+ local, err := localdb.New(filepath.Join(dir, "qfs.db"))
+ if err != nil {
+ t.Fatalf("init local db: %v", err)
+ }
+
+ service := syncsvc.NewService(nil, local)
+ h, err := NewSyncHandler(local, service, nil, filepath.Join("web", "templates"), 5*time.Minute)
+ if err != nil {
+ t.Fatalf("new sync handler: %v", err)
+ }
+
+ router := gin.New()
+ router.GET("/api/sync/readiness", h.GetReadiness)
+ router.POST("/api/sync/push", h.PushPendingChanges)
+
+ readinessResp := httptest.NewRecorder()
+ readinessReq, _ := http.NewRequest(http.MethodGet, "/api/sync/readiness", nil)
+ router.ServeHTTP(readinessResp, readinessReq)
+ if readinessResp.Code != http.StatusOK {
+ t.Fatalf("unexpected readiness status: %d", readinessResp.Code)
+ }
+
+ var readinessBody map[string]any
+ if err := json.Unmarshal(readinessResp.Body.Bytes(), &readinessBody); err != nil {
+ t.Fatalf("decode readiness body: %v", err)
+ }
+ if blocked, _ := readinessBody["blocked"].(bool); !blocked {
+ t.Fatalf("expected blocked readiness, got %v", readinessBody["blocked"])
+ }
+
+ pushResp := httptest.NewRecorder()
+ pushReq, _ := http.NewRequest(http.MethodPost, "/api/sync/push", nil)
+ router.ServeHTTP(pushResp, pushReq)
+ if pushResp.Code != http.StatusLocked {
+ t.Fatalf("expected 423 for blocked sync push, got %d body=%s", pushResp.Code, pushResp.Body.String())
+ }
+
+ var pushBody map[string]any
+ if err := json.Unmarshal(pushResp.Body.Bytes(), &pushBody); err != nil {
+ t.Fatalf("decode push body: %v", err)
+ }
+ if pushBody["reason_text"] == nil || pushBody["reason_text"] == "" {
+ t.Fatalf("expected reason_text in blocked response, got %v", pushBody)
+ }
+}
diff --git a/internal/services/sync/readiness.go b/internal/services/sync/readiness.go
new file mode 100644
index 0000000..8e7b908
--- /dev/null
+++ b/internal/services/sync/readiness.go
@@ -0,0 +1,389 @@
+package sync
+
+import (
+ "bufio"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "log/slog"
+ "strconv"
+ "strings"
+ "time"
+
+ "git.mchus.pro/mchus/quoteforge/internal/appmeta"
+ "git.mchus.pro/mchus/quoteforge/internal/localdb"
+ "gorm.io/gorm"
+)
+
+const (
+ ReadinessReady = "ready"
+ ReadinessBlocked = "blocked"
+ ReadinessUnknown = "unknown"
+)
+
+var ErrSyncBlockedByReadiness = errors.New("sync blocked by readiness guard")
+
+type SyncReadiness struct {
+ Status string `json:"status"`
+ Blocked bool `json:"blocked"`
+ ReasonCode string `json:"reason_code,omitempty"`
+ ReasonText string `json:"reason_text,omitempty"`
+ RequiredMinAppVersion *string `json:"required_min_app_version,omitempty"`
+ LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
+}
+
+type SyncBlockedError struct {
+ Readiness SyncReadiness
+}
+
+func (e *SyncBlockedError) Error() string {
+ if e == nil {
+ return ErrSyncBlockedByReadiness.Error()
+ }
+ if strings.TrimSpace(e.Readiness.ReasonText) != "" {
+ return e.Readiness.ReasonText
+ }
+ return ErrSyncBlockedByReadiness.Error()
+}
+
+func (s *Service) EnsureReadinessForSync() (*SyncReadiness, error) {
+ readiness, err := s.GetReadiness()
+ if err != nil {
+ return nil, err
+ }
+ if readiness.Blocked {
+ return readiness, &SyncBlockedError{Readiness: *readiness}
+ }
+ return readiness, nil
+}
+
+func (s *Service) GetReadiness() (*SyncReadiness, error) {
+ now := time.Now().UTC()
+ if !s.isOnline() {
+ return s.blockedReadiness(
+ now,
+ "OFFLINE_UNVERIFIED_SCHEMA",
+ "Синхронизация недоступна: нет соединения с сервером и нельзя проверить миграции локальной БД.",
+ nil,
+ )
+ }
+
+ mariaDB, err := s.getDB()
+ if err != nil || mariaDB == nil {
+ return s.blockedReadiness(
+ now,
+ "OFFLINE_UNVERIFIED_SCHEMA",
+ "Синхронизация недоступна: нет соединения с сервером и нельзя проверить миграции локальной БД.",
+ nil,
+ )
+ }
+
+ migrations, err := listActiveClientMigrations(mariaDB)
+ if err != nil {
+ return s.blockedReadiness(
+ now,
+ "REMOTE_MIGRATION_REGISTRY_UNAVAILABLE",
+ "Синхронизация заблокирована: не удалось проверить централизованные миграции локальной БД.",
+ nil,
+ )
+ }
+
+ for i := range migrations {
+ m := migrations[i]
+ if strings.TrimSpace(m.MinAppVersion) != "" {
+ if compareVersions(appmeta.Version(), m.MinAppVersion) < 0 {
+ min := m.MinAppVersion
+ return s.blockedReadiness(
+ now,
+ "MIN_APP_VERSION_REQUIRED",
+ fmt.Sprintf("Требуется обновление приложения до версии %s для безопасной синхронизации.", m.MinAppVersion),
+ &min,
+ )
+ }
+ }
+ }
+
+ if err := s.applyMissingRemoteMigrations(migrations); err != nil {
+ if strings.Contains(strings.ToLower(err.Error()), "checksum") {
+ return s.blockedReadiness(
+ now,
+ "REMOTE_MIGRATION_CHECKSUM_MISMATCH",
+ "Синхронизация заблокирована: контрольная сумма миграции не совпадает.",
+ nil,
+ )
+ }
+ return s.blockedReadiness(
+ now,
+ "LOCAL_MIGRATION_APPLY_FAILED",
+ "Синхронизация заблокирована: не удалось применить миграции локальной БД.",
+ nil,
+ )
+ }
+
+ if err := s.reportClientSchemaState(mariaDB, now); err != nil {
+ slog.Warn("failed to report client schema state", "error", err)
+ }
+
+ ready := &SyncReadiness{Status: ReadinessReady, Blocked: false, LastCheckedAt: &now}
+ if setErr := s.localDB.SetSyncGuardState(ReadinessReady, "", "", nil, &now); setErr != nil {
+ slog.Warn("failed to persist sync guard state", "error", setErr)
+ }
+ return ready, nil
+}
+
+func (s *Service) blockedReadiness(now time.Time, code, text string, minVersion *string) (*SyncReadiness, error) {
+ readiness := &SyncReadiness{
+ Status: ReadinessBlocked,
+ Blocked: true,
+ ReasonCode: code,
+ ReasonText: text,
+ RequiredMinAppVersion: minVersion,
+ LastCheckedAt: &now,
+ }
+ if err := s.localDB.SetSyncGuardState(ReadinessBlocked, code, text, minVersion, &now); err != nil {
+ slog.Warn("failed to persist blocked sync guard state", "error", err)
+ }
+ return readiness, nil
+}
+
+func (s *Service) isOnline() bool {
+ if s.directDB != nil {
+ return true
+ }
+ if s.connMgr == nil {
+ return false
+ }
+ return s.connMgr.IsOnline()
+}
+
+type clientLocalMigration struct {
+ ID string `gorm:"column:id"`
+ Name string `gorm:"column:name"`
+ SQLText string `gorm:"column:sql_text"`
+ Checksum string `gorm:"column:checksum"`
+ MinAppVersion string `gorm:"column:min_app_version"`
+ OrderNo int `gorm:"column:order_no"`
+ CreatedAt time.Time `gorm:"column:created_at"`
+}
+
+func listActiveClientMigrations(db *gorm.DB) ([]clientLocalMigration, error) {
+ if strings.EqualFold(db.Dialector.Name(), "sqlite") {
+ return []clientLocalMigration{}, nil
+ }
+ if err := ensureClientMigrationRegistryTable(db); err != nil {
+ return nil, err
+ }
+
+ rows := make([]clientLocalMigration, 0)
+ if err := db.Raw(`
+ SELECT id, name, sql_text, checksum, COALESCE(min_app_version, '') AS min_app_version, order_no, created_at
+ FROM qt_client_local_migrations
+ WHERE is_active = 1
+ ORDER BY order_no ASC, created_at ASC, id ASC
+ `).Scan(&rows).Error; err != nil {
+ return nil, fmt.Errorf("load client local migrations: %w", err)
+ }
+
+ return rows, nil
+}
+
+func ensureClientMigrationRegistryTable(db *gorm.DB) error {
+ if err := db.Exec(`
+ CREATE TABLE IF NOT EXISTS qt_client_local_migrations (
+ id VARCHAR(128) NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ sql_text LONGTEXT NOT NULL,
+ checksum VARCHAR(128) NOT NULL,
+ min_app_version VARCHAR(64) NULL,
+ order_no INT NOT NULL DEFAULT 0,
+ is_active TINYINT(1) NOT NULL DEFAULT 1,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ INDEX idx_qt_client_local_migrations_active_order (is_active, order_no, created_at)
+ )
+ `).Error; err != nil {
+ return err
+ }
+ return db.Exec(`
+ CREATE TABLE IF NOT EXISTS qt_client_schema_state (
+ username VARCHAR(100) NOT NULL,
+ last_applied_migration_id VARCHAR(128) NULL,
+ app_version VARCHAR(64) NULL,
+ last_checked_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL,
+ PRIMARY KEY (username),
+ INDEX idx_qt_client_schema_state_checked (last_checked_at)
+ )
+ `).Error
+}
+
+func (s *Service) applyMissingRemoteMigrations(migrations []clientLocalMigration) error {
+ for i := range migrations {
+ m := migrations[i]
+ computedChecksum := digestSQL(m.SQLText)
+ checksum := strings.TrimSpace(m.Checksum)
+ if checksum == "" {
+ checksum = computedChecksum
+ } else if !strings.EqualFold(checksum, computedChecksum) {
+ return fmt.Errorf("checksum mismatch for migration %s", m.ID)
+ }
+
+ applied, err := s.localDB.GetRemoteMigrationApplied(m.ID)
+ if err == nil {
+ if strings.TrimSpace(applied.Checksum) != checksum {
+ return fmt.Errorf("checksum mismatch for migration %s", m.ID)
+ }
+ continue
+ }
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return fmt.Errorf("check local applied migration %s: %w", m.ID, err)
+ }
+
+ if strings.TrimSpace(m.SQLText) == "" {
+ if err := s.localDB.UpsertRemoteMigrationApplied(m.ID, checksum, appmeta.Version(), time.Now().UTC()); err != nil {
+ return fmt.Errorf("mark empty migration %s as applied: %w", m.ID, err)
+ }
+ continue
+ }
+
+ statements := splitSQLStatementsLite(m.SQLText)
+ if err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
+ for _, stmt := range statements {
+ if err := tx.Exec(stmt).Error; err != nil {
+ return fmt.Errorf("apply migration %s statement %q: %w", m.ID, stmt, err)
+ }
+ }
+ return nil
+ }); err != nil {
+ return err
+ }
+
+ if err := s.localDB.UpsertRemoteMigrationApplied(m.ID, checksum, appmeta.Version(), time.Now().UTC()); err != nil {
+ return fmt.Errorf("record applied migration %s: %w", m.ID, err)
+ }
+ }
+ return nil
+}
+
+func splitSQLStatementsLite(script string) []string {
+ scanner := bufio.NewScanner(strings.NewReader(script))
+ scanner.Buffer(make([]byte, 1024), 1024*1024)
+
+ lines := make([]string, 0, 64)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" || strings.HasPrefix(line, "--") {
+ continue
+ }
+ lines = append(lines, scanner.Text())
+ }
+ combined := strings.Join(lines, "\n")
+ raw := strings.Split(combined, ";")
+ stmts := make([]string, 0, len(raw))
+ for _, stmt := range raw {
+ trimmed := strings.TrimSpace(stmt)
+ if trimmed == "" {
+ continue
+ }
+ stmts = append(stmts, trimmed)
+ }
+ return stmts
+}
+
+func digestSQL(sqlText string) string {
+ hash := sha256.Sum256([]byte(sqlText))
+ return hex.EncodeToString(hash[:])
+}
+
+func compareVersions(left, right string) int {
+ leftParts := normalizeVersionParts(left)
+ rightParts := normalizeVersionParts(right)
+ maxLen := len(leftParts)
+ if len(rightParts) > maxLen {
+ maxLen = len(rightParts)
+ }
+ for i := 0; i < maxLen; i++ {
+ lv := 0
+ rv := 0
+ if i < len(leftParts) {
+ lv = leftParts[i]
+ }
+ if i < len(rightParts) {
+ rv = rightParts[i]
+ }
+ if lv < rv {
+ return -1
+ }
+ if lv > rv {
+ return 1
+ }
+ }
+ return 0
+}
+
+func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) error {
+ if strings.EqualFold(mariaDB.Dialector.Name(), "sqlite") {
+ return nil
+ }
+ username := strings.TrimSpace(s.localDB.GetDBUser())
+ if username == "" {
+ return nil
+ }
+ lastMigrationID := ""
+ if id, err := s.localDB.GetLatestAppliedRemoteMigrationID(); err == nil {
+ lastMigrationID = id
+ }
+ return mariaDB.Exec(`
+ INSERT INTO qt_client_schema_state (username, last_applied_migration_id, app_version, last_checked_at, updated_at)
+ VALUES (?, ?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE
+ last_applied_migration_id = VALUES(last_applied_migration_id),
+ app_version = VALUES(app_version),
+ last_checked_at = VALUES(last_checked_at),
+ updated_at = VALUES(updated_at)
+ `, username, lastMigrationID, appmeta.Version(), checkedAt, checkedAt).Error
+}
+
+func normalizeVersionParts(v string) []int {
+ trimmed := strings.TrimSpace(v)
+ trimmed = strings.TrimPrefix(trimmed, "v")
+ chunks := strings.Split(trimmed, ".")
+ parts := make([]int, 0, len(chunks))
+ for _, chunk := range chunks {
+ clean := strings.TrimSpace(chunk)
+ if clean == "" {
+ parts = append(parts, 0)
+ continue
+ }
+ n := 0
+ for i := 0; i < len(clean); i++ {
+ if clean[i] < '0' || clean[i] > '9' {
+ clean = clean[:i]
+ break
+ }
+ }
+ if clean != "" {
+ if parsed, err := strconv.Atoi(clean); err == nil {
+ n = parsed
+ }
+ }
+ parts = append(parts, n)
+ }
+ return parts
+}
+
+func toReadinessFromState(state *localdb.LocalSyncGuardState) *SyncReadiness {
+ if state == nil {
+ return nil
+ }
+ blocked := state.Status == ReadinessBlocked
+ return &SyncReadiness{
+ Status: state.Status,
+ Blocked: blocked,
+ ReasonCode: state.ReasonCode,
+ ReasonText: state.ReasonText,
+ RequiredMinAppVersion: state.RequiredMinAppVersion,
+ LastCheckedAt: state.LastCheckedAt,
+ }
+}
diff --git a/qfs b/qfs
new file mode 100755
index 0000000..0463f73
Binary files /dev/null and b/qfs differ
diff --git a/todo.md b/todo.md
new file mode 100644
index 0000000..876788b
--- /dev/null
+++ b/todo.md
@@ -0,0 +1,78 @@
+# QuoteForge — План очистки (удаление admin pricing)
+
+Цель: убрать всё, что связано с администрированием цен, складскими справками, алертами.
+Оставить: конфигуратор, проекты, read-only просмотр прайслистов, sync, offline-first.
+
+---
+
+## 1. Удалить файлы
+
+- [x] `internal/handlers/pricing.go` (40.6KB) — весь admin pricing UI
+- [x] `internal/services/pricing/` — весь пакет расчёта цен
+- [x] `internal/services/pricelist/` — весь пакет управления прайслистами
+- [x] `internal/services/stock_import.go` — импорт складских справок
+- [x] `internal/services/alerts/` — весь пакет алертов
+- [x] `internal/warehouse/` — алгоритмы расчёта цен по складу
+- [x] `web/templates/admin_pricing.html` (109KB) — страница admin pricing
+- [x] `cmd/cron/` — cron jobs (cleanup-pricelists, update-prices, update-popularity)
+- [x] `cmd/importer/` — утилита импорта данных
+
+## 2. Упростить `internal/handlers/pricelist.go` (read-only)
+
+Read-only методы (List, Get, GetItems, GetLotNames, GetLatest) уже работают
+только через `h.localDB` (SQLite) без `pricelist.Service`.
+
+- [x] Убрать поле `service *pricelist.Service` из структуры `PricelistHandler`
+- [x] Изменить конструктор: `NewPricelistHandler(localDB *localdb.LocalDB)`
+- [x] Удалить write-методы: `Create()`, `CreateWithProgress()`, `Delete()`, `SetActive()`, `CanWrite()`
+- [x] Удалить метод `refreshLocalPricelistCacheFromServer()` (зависит от service)
+- [x] Удалить import `pricelist` пакета
+- [x] Оставить: `List()`, `Get()`, `GetItems()`, `GetLotNames()`, `GetLatest()`
+
+## 3. Упростить `cmd/qfs/main.go`
+
+- [x] Удалить создание сервисов: `pricingService`, `alertService`, `pricelistService`, `stockImportService`
+- [x] Удалить хэндлер: `pricingHandler`
+- [x] Изменить создание `pricelistHandler`: `NewPricelistHandler(local)` (без service)
+- [x] Удалить repositories: `priceRepo`, `alertRepo` (statsRepo оставить — nil-safe)
+- [x] Удалить все routes `/api/admin/pricing/*` (строки ~1407-1430)
+- [x] Из `/api/pricelists/*` оставить только read-only:
+ - `GET ""` (List), `GET "/latest"`, `GET "/:id"`, `GET "/:id/items"`, `GET "/:id/lots"`
+- [x] Удалить write routes: `POST ""`, `POST "/create-with-progress"`, `PATCH "/:id/active"`, `DELETE "/:id"`, `GET "/can-write"`
+- [x] Удалить web page `/admin/pricing`
+- [x] Исправить `/pricelists` — вместо redirect на admin/pricing сделать страницу
+- [x] В `QuoteService` конструкторе: передавать `nil` для `pricingService`
+- [x] Удалить imports: `pricing`, `pricelist`, `alerts` пакеты
+
+## 4. Упростить `handlers/web.go`
+
+- [x] Удалить из `simplePages`: `admin_pricing.html`
+- [x] Удалить метод: `AdminPricing()`
+- [x] Оставить все остальные методы включая `Pricelists()` и `PricelistDetail()`
+
+## 5. Упростить `base.html` (навигация)
+
+- [x] Убрать ссылку "Администратор цен"
+- [x] Добавить ссылку "Прайслисты" (на `/pricelists`)
+- [x] Оставить: "Мои проекты", "Прайслисты", sync indicator
+
+## 6. Sync — оставить полностью
+
+- Background worker: pull компоненты + прайслисты, push конфигурации
+- Все `/api/sync/*` endpoints остаются
+- Это ядро offline-first архитектуры
+
+## 7. Верификация
+
+- [x] `go build ./cmd/qfs` — компилируется
+- [x] `go vet ./...` — без ошибок
+- [ ] Запуск → `/configs` работает
+- [ ] `/pricelists` — read-only список работает
+- [ ] `/pricelists/:id` — детали работают
+- [ ] Sync с сервером работает
+- [ ] Нет ссылок на admin pricing в UI
+
+## 8. Обновить CLAUDE.md
+- [x] Убрать разделы про admin pricing, stock import, alerts, cron
+- [x] Обновить API endpoints список
+- [x] Обновить описание приложения
diff --git a/web/templates/pricelists.html b/web/templates/pricelists.html
index 126d376..1578d1e 100644
--- a/web/templates/pricelists.html
+++ b/web/templates/pricelists.html
@@ -12,6 +12,7 @@
Версия
+ Тип
Дата
Автор
Позиций
@@ -22,7 +23,7 @@