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 @@ - Загрузка... + Загрузка... @@ -87,7 +88,7 @@ } catch (e) { document.getElementById('pricelists-body').innerHTML = ` - + Ошибка загрузки: ${e.message} @@ -99,7 +100,7 @@ if (pricelists.length === 0) { document.getElementById('pricelists-body').innerHTML = ` - + Прайслисты не найдены. ${canWrite ? 'Создайте первый прайслист.' : ''} @@ -109,6 +110,12 @@ const html = pricelists.map(pl => { const date = new Date(pl.created_at).toLocaleDateString('ru-RU'); + const sourceToType = { + estimate: 'estimate', + warehouse: 'stock', + competitor: 'b2b' + }; + const pricelistType = sourceToType[pl.source] || pl.source || '-'; const statusClass = pl.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'; const statusText = pl.is_active ? 'Активен' : 'Неактивен'; @@ -122,6 +129,7 @@ ${pl.version} + ${pricelistType} ${date} ${pl.created_by || '-'} ${pl.item_count}