Add pricelist type column and commit pending changes

This commit is contained in:
2026-02-08 10:03:24 +03:00
parent 593280de99
commit f609d2ce35
10 changed files with 548 additions and 3 deletions
+6
View File
@@ -21,6 +21,12 @@
- MariaDB используется как сервер синхронизации - MariaDB используется как сервер синхронизации
- Background worker: периодический sync push+pull - 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 ## Key SQLite Data
- `connection_settings` - `connection_settings`
- `local_components` - `local_components`
Vendored Executable
BIN
View File
Binary file not shown.
Vendored Executable
BIN
View File
Binary file not shown.
Vendored Executable
BIN
View File
Binary file not shown.
Vendored Executable
BIN
View File
Binary file not shown.
+64
View File
@@ -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)
}
}
+389
View File
@@ -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,
}
}
Executable
BIN
View File
Binary file not shown.
+78
View File
@@ -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] Обновить описание приложения
+11 -3
View File
@@ -12,6 +12,7 @@
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Тип</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Позиций</th> <th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Позиций</th>
@@ -22,7 +23,7 @@
</thead> </thead>
<tbody id="pricelists-body" class="bg-white divide-y divide-gray-200"> <tbody id="pricelists-body" class="bg-white divide-y divide-gray-200">
<tr> <tr>
<td colspan="7" class="px-6 py-4 text-center text-gray-500">Загрузка...</td> <td colspan="8" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -87,7 +88,7 @@
} catch (e) { } catch (e) {
document.getElementById('pricelists-body').innerHTML = ` document.getElementById('pricelists-body').innerHTML = `
<tr> <tr>
<td colspan="7" class="px-6 py-4 text-center text-red-500"> <td colspan="8" class="px-6 py-4 text-center text-red-500">
Ошибка загрузки: ${e.message} Ошибка загрузки: ${e.message}
</td> </td>
</tr> </tr>
@@ -99,7 +100,7 @@
if (pricelists.length === 0) { if (pricelists.length === 0) {
document.getElementById('pricelists-body').innerHTML = ` document.getElementById('pricelists-body').innerHTML = `
<tr> <tr>
<td colspan="7" class="px-6 py-4 text-center text-gray-500"> <td colspan="8" class="px-6 py-4 text-center text-gray-500">
Прайслисты не найдены. ${canWrite ? 'Создайте первый прайслист.' : ''} Прайслисты не найдены. ${canWrite ? 'Создайте первый прайслист.' : ''}
</td> </td>
</tr> </tr>
@@ -109,6 +110,12 @@
const html = pricelists.map(pl => { const html = pricelists.map(pl => {
const date = new Date(pl.created_at).toLocaleDateString('ru-RU'); 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 statusClass = pl.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
const statusText = pl.is_active ? 'Активен' : 'Неактивен'; const statusText = pl.is_active ? 'Активен' : 'Неактивен';
@@ -122,6 +129,7 @@
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<span class="font-mono text-sm">${pl.version}</span> <span class="font-mono text-sm">${pl.version}</span>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${pricelistType}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${date}</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${date}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${pl.created_by || '-'}</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${pl.created_by || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${pl.item_count}</td> <td class="px-6 py-4 whitespace-nowrap text-center text-sm">${pl.item_count}</td>