6 Commits

Author SHA1 Message Date
Mikhail Chusavitin
7de0f359b6 Pricing tab: per-LOT row expansion with rowspan grouping
- Reorder columns: PN вендора / Описание / LOT / Кол-во / Estimate / Склад / Конкуренты / Ручная цена
- Explode multi-LOT BOM rows into individual LOT sub-rows; PN вендора + Описание use rowspan to span the group
- Rename "Своя цена" → "Ручная цена", "Проставить цены BOM" → "BOM Цена"
- CSV export reads PN/Desc/LOT from data attributes to handle rowspan offset correctly
- Document pricing tab layout contract in bible-local/02-architecture.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:53:32 +03:00
Mikhail Chusavitin
a8d8d7dfa9 Treat current configuration as main 2026-03-17 18:43:49 +03:00
Mikhail Chusavitin
20ce0124be Vendor frontend assets locally 2026-03-17 18:41:53 +03:00
Mikhail Chusavitin
b0a106415f Make sync status non-blocking 2026-03-17 18:34:28 +03:00
Mikhail Chusavitin
a054fc7564 Version BOM and pricing changes 2026-03-17 18:24:09 +03:00
Mikhail Chusavitin
68cd087356 Fix incomplete pricelist sync status 2026-03-17 12:05:02 +03:00
20 changed files with 954 additions and 236 deletions

View File

@@ -35,6 +35,8 @@ Readiness guard:
- blocked sync returns `423 Locked` with a machine-readable reason;
- local work continues even when sync is blocked.
- sync metadata updates must preserve project `updated_at`; sync time belongs in `synced_at`, not in the user-facing last-modified timestamp.
- pricelist pull must persist a new local snapshot atomically: header and items appear together, and `last_pricelist_sync` advances only after item download succeeds.
- UI sync status must distinguish "last sync failed" from "up to date"; if the app can prove newer server pricelist data exists, the indicator must say local cache is incomplete.
## Pricing contract
@@ -46,16 +48,55 @@ Rules:
- latest pricelist selection ignores snapshots without items;
- auto pricelist mode stays auto and must not be persisted as an explicit resolved ID.
## Pricing tab layout
The Pricing tab (Ценообразование) has two tables: Buy (Цена покупки) and Sale (Цена продажи).
Column order (both tables):
```
PN вендора | Описание | LOT | Кол-во | Estimate | Склад | Конкуренты | Ручная цена
```
Per-LOT row expansion rules:
- each `lot_mappings` entry in a BOM row becomes its own table row with its own quantity and prices;
- `baseLot` (resolved LOT without an explicit mapping) is treated as the first sub-row with `quantity_per_pn` from `_getRowLotQtyPerPN`;
- when one vendor PN expands into N LOT sub-rows, PN вендора and Описание cells use `rowspan="N"` and appear only on the first sub-row;
- a visual top border (`border-t border-gray-200`) separates each vendor PN group.
Vendor price attachment:
- `vendorOrig` and `vendorOrigUnit` (BOM unit/total price) are attached to the first LOT sub-row only;
- subsequent sub-rows carry empty `data-vendor-orig` so `setPricingCustomPriceFromVendor` counts each vendor PN exactly once.
Controls terminology:
- custom price input is labeled **Ручная цена** (not "Своя цена");
- the button that fills custom price from BOM totals is labeled **BOM Цена** (not "Проставить цены BOM").
CSV export reads PN вендора, Описание, and LOT from `data-vendor-pn`, `data-desc`, `data-lot` row attributes to bypass the rowspan cell offset problem.
## Configuration versioning
Configuration revisions are append-only snapshots stored in `local_configuration_versions`.
Rules:
- create a new revision only when spec or price content changes;
- the editable working configuration is always the implicit head named `main`; UI must not switch the user to a numbered revision after save;
- create a new revision when spec, BOM, or pricing content changes;
- revision history is retrospective: the revisions page shows past snapshots, not the current `main` state;
- rollback creates a new head revision from an old snapshot;
- rename, reorder, project move, and similar operational edits do not create a new revision snapshot;
- revision deduplication includes `items`, `server_count`, `total_price`, `custom_price`, `vendor_spec`, pricelist selectors, `disable_price_refresh`, and `only_in_stock`;
- BOM updates must use version-aware save flow, not a direct SQL field update;
- current revision pointer must be recoverable if legacy or damaged rows are found locally.
## Sync UX
UI-facing sync status must never block on live MariaDB calls.
Rules:
- navbar sync indicator and sync info modal read only local cached state from SQLite/app settings;
- background/manual sync may talk to MariaDB, but polling endpoints must stay fast even on slow or broken connections;
- any MariaDB timeout/invalid-connection during sync must invalidate the cached remote handle immediately so UI stops treating the connection as healthy.
## Naming collisions
UI-driven rename and copy flows use one suffix convention for conflicts.

View File

@@ -238,6 +238,22 @@ func (cm *ConnectionManager) Disconnect() {
cm.lastError = nil
}
// MarkOffline closes the current connection and preserves the last observed error.
func (cm *ConnectionManager) MarkOffline(err error) {
cm.mu.Lock()
defer cm.mu.Unlock()
if cm.db != nil {
sqlDB, dbErr := cm.db.DB()
if dbErr == nil {
sqlDB.Close()
}
}
cm.db = nil
cm.lastError = err
cm.lastCheck = time.Now()
}
// GetLastError returns the last connection error (thread-safe)
func (cm *ConnectionManager) GetLastError() error {
cm.mu.RLock()

View File

@@ -6,6 +6,7 @@ import (
"html/template"
"log/slog"
"net/http"
"strings"
stdsync "sync"
"time"
@@ -49,15 +50,20 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
// SyncStatusResponse represents the sync status
type SyncStatusResponse struct {
LastComponentSync *time.Time `json:"last_component_sync"`
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
IsOnline bool `json:"is_online"`
ComponentsCount int64 `json:"components_count"`
PricelistsCount int64 `json:"pricelists_count"`
ServerPricelists int `json:"server_pricelists"`
NeedComponentSync bool `json:"need_component_sync"`
NeedPricelistSync bool `json:"need_pricelist_sync"`
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
LastComponentSync *time.Time `json:"last_component_sync"`
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"`
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"`
LastPricelistSyncError string `json:"last_pricelist_sync_error,omitempty"`
HasIncompleteServerSync bool `json:"has_incomplete_server_sync"`
KnownServerChangesMiss bool `json:"known_server_changes_missing"`
IsOnline bool `json:"is_online"`
ComponentsCount int64 `json:"components_count"`
PricelistsCount int64 `json:"pricelists_count"`
ServerPricelists int `json:"server_pricelists"`
NeedComponentSync bool `json:"need_component_sync"`
NeedPricelistSync bool `json:"need_pricelist_sync"`
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
}
type SyncReadinessResponse struct {
@@ -72,42 +78,34 @@ type SyncReadinessResponse struct {
// GetStatus returns current sync status
// GET /api/sync/status
func (h *SyncHandler) GetStatus(c *gin.Context) {
// Check online status by pinging MariaDB
isOnline := h.checkOnline()
// Get sync times
connStatus := h.connMgr.GetStatus()
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
lastComponentSync := h.localDB.GetComponentSyncTime()
lastPricelistSync := h.localDB.GetLastSyncTime()
// Get counts
componentsCount := h.localDB.CountLocalComponents()
pricelistsCount := h.localDB.CountLocalPricelists()
// Get server pricelist count if online
serverPricelists := 0
needPricelistSync := false
if isOnline {
status, err := h.syncService.GetStatus()
if err == nil {
serverPricelists = status.ServerPricelists
needPricelistSync = status.NeedsSync
}
}
// Check if component sync is needed (older than 24 hours)
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
needComponentSync := h.localDB.NeedComponentSync(24)
readiness := h.getReadinessCached(10 * time.Second)
readiness := h.getReadinessLocal()
c.JSON(http.StatusOK, SyncStatusResponse{
LastComponentSync: lastComponentSync,
LastPricelistSync: lastPricelistSync,
IsOnline: isOnline,
ComponentsCount: componentsCount,
PricelistsCount: pricelistsCount,
ServerPricelists: serverPricelists,
NeedComponentSync: needComponentSync,
NeedPricelistSync: needPricelistSync,
Readiness: readiness,
LastComponentSync: lastComponentSync,
LastPricelistSync: lastPricelistSync,
LastPricelistAttemptAt: lastPricelistAttemptAt,
LastPricelistSyncStatus: lastPricelistSyncStatus,
LastPricelistSyncError: lastPricelistSyncError,
HasIncompleteServerSync: hasFailedSync,
KnownServerChangesMiss: hasFailedSync,
IsOnline: isOnline,
ComponentsCount: componentsCount,
PricelistsCount: pricelistsCount,
ServerPricelists: 0,
NeedComponentSync: needComponentSync,
NeedPricelistSync: lastPricelistSync == nil || hasFailedSync,
Readiness: readiness,
})
}
@@ -474,8 +472,13 @@ type SyncInfoResponse struct {
DBName string `json:"db_name"`
// Status
IsOnline bool `json:"is_online"`
LastSyncAt *time.Time `json:"last_sync_at"`
IsOnline bool `json:"is_online"`
LastSyncAt *time.Time `json:"last_sync_at"`
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"`
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"`
LastPricelistSyncError string `json:"last_pricelist_sync_error,omitempty"`
NeedPricelistSync bool `json:"need_pricelist_sync"`
HasIncompleteServerSync bool `json:"has_incomplete_server_sync"`
// Statistics
LotCount int64 `json:"lot_count"`
@@ -511,8 +514,8 @@ type SyncError struct {
// GetInfo returns sync information for modal
// GET /api/sync/info
func (h *SyncHandler) GetInfo(c *gin.Context) {
// Check online status by pinging MariaDB
isOnline := h.checkOnline()
connStatus := h.connMgr.GetStatus()
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
// Get DB connection info
var dbHost, dbUser, dbName string
@@ -524,6 +527,12 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
// Get sync times
lastPricelistSync := h.localDB.GetLastSyncTime()
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
needPricelistSync := lastPricelistSync == nil || hasFailedSync
hasIncompleteServerSync := hasFailedSync
// Get local counts
configCount := h.localDB.CountConfigurations()
@@ -556,22 +565,27 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
syncErrors = syncErrors[:10]
}
readiness := h.getReadinessCached(10 * time.Second)
readiness := h.getReadinessLocal()
c.JSON(http.StatusOK, SyncInfoResponse{
DBHost: dbHost,
DBUser: dbUser,
DBName: dbName,
IsOnline: isOnline,
LastSyncAt: lastPricelistSync,
LotCount: componentCount,
LotLogCount: pricelistCount,
ConfigCount: configCount,
ProjectCount: projectCount,
PendingChanges: changes,
ErrorCount: errorCount,
Errors: syncErrors,
Readiness: readiness,
DBHost: dbHost,
DBUser: dbUser,
DBName: dbName,
IsOnline: isOnline,
LastSyncAt: lastPricelistSync,
LastPricelistAttemptAt: lastPricelistAttemptAt,
LastPricelistSyncStatus: lastPricelistSyncStatus,
LastPricelistSyncError: lastPricelistSyncError,
NeedPricelistSync: needPricelistSync,
HasIncompleteServerSync: hasIncompleteServerSync,
LotCount: componentCount,
LotLogCount: pricelistCount,
ConfigCount: configCount,
ProjectCount: projectCount,
PendingChanges: changes,
ErrorCount: errorCount,
Errors: syncErrors,
Readiness: readiness,
})
}
@@ -626,15 +640,33 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
// Get pending count
pendingCount := h.localDB.GetPendingCount()
readiness := h.getReadinessCached(10 * time.Second)
readiness := h.getReadinessLocal()
isBlocked := readiness != nil && readiness.Blocked
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
hasIncompleteServerSync := hasFailedSync
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked)
data := gin.H{
"IsOffline": isOffline,
"PendingCount": pendingCount,
"IsBlocked": isBlocked,
"IsOffline": isOffline,
"PendingCount": pendingCount,
"IsBlocked": isBlocked,
"HasFailedSync": hasFailedSync,
"HasIncompleteServerSync": hasIncompleteServerSync,
"SyncIssueTitle": func() string {
if hasIncompleteServerSync {
return "Последняя синхронизация прайслистов прервалась. На сервере есть изменения, которые не загружены локально."
}
if hasFailedSync {
if lastPricelistSyncError != "" {
return lastPricelistSyncError
}
return "Последняя синхронизация прайслистов завершилась ошибкой."
}
return ""
}(),
"BlockedReason": func() string {
if readiness == nil {
return ""
@@ -651,20 +683,29 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
}
}
func (h *SyncHandler) getReadinessCached(maxAge time.Duration) *sync.SyncReadiness {
func (h *SyncHandler) getReadinessLocal() *sync.SyncReadiness {
h.readinessMu.Lock()
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < maxAge {
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < 10*time.Second {
cached := *h.readinessCached
h.readinessMu.Unlock()
return &cached
}
h.readinessMu.Unlock()
readiness, err := h.syncService.GetReadiness()
if err != nil && readiness == nil {
state, err := h.localDB.GetSyncGuardState()
if err != nil || state == nil {
return nil
}
readiness := &sync.SyncReadiness{
Status: state.Status,
Blocked: state.Status == sync.ReadinessBlocked,
ReasonCode: state.ReasonCode,
ReasonText: state.ReasonText,
RequiredMinAppVersion: state.RequiredMinAppVersion,
LastCheckedAt: state.LastCheckedAt,
}
h.readinessMu.Lock()
h.readinessCached = readiness
h.readinessCachedAt = time.Now()

View File

@@ -1,7 +1,6 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strings"
@@ -14,11 +13,15 @@ import (
// VendorSpecHandler handles vendor BOM spec operations for a configuration.
type VendorSpecHandler struct {
localDB *localdb.LocalDB
localDB *localdb.LocalDB
configService *services.LocalConfigurationService
}
func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler {
return &VendorSpecHandler{localDB: localDB}
return &VendorSpecHandler{
localDB: localDB,
configService: services.NewLocalConfigurationService(localDB, nil, nil, func() bool { return false }),
}
}
// lookupConfig finds an active configuration by UUID using the standard localDB method.
@@ -80,12 +83,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
}
spec := localdb.VendorSpec(body.VendorSpec)
specJSON, err := json.Marshal(spec)
if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
if err := h.localDB.DB().Model(cfg).Update("vendor_spec", string(specJSON)).Error; err != nil {
if _, err := h.configService.UpdateVendorSpecNoAuth(cfg.UUID, spec); err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
@@ -194,13 +192,7 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
})
}
itemsJSON, err := json.Marshal(newItems)
if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
if err := h.localDB.DB().Model(cfg).Update("items", string(itemsJSON)).Error; err != nil {
if _, err := h.configService.ApplyVendorSpecItemsNoAuth(cfg.UUID, newItems); err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}

View File

@@ -95,3 +95,60 @@ func TestConfigurationSnapshotPreservesBusinessFields(t *testing.T) {
t.Fatalf("lot mappings lost in snapshot: %+v", decoded.VendorSpec)
}
}
func TestConfigurationFingerprintIncludesPricingSelectorsAndVendorSpec(t *testing.T) {
estimateID := uint(11)
warehouseID := uint(22)
competitorID := uint(33)
base := &LocalConfiguration{
UUID: "cfg-1",
Name: "Config",
ServerCount: 1,
Items: LocalConfigItems{{LotName: "LOT_A", Quantity: 1, UnitPrice: 100}},
PricelistID: &estimateID,
WarehousePricelistID: &warehouseID,
CompetitorPricelistID: &competitorID,
DisablePriceRefresh: true,
OnlyInStock: true,
VendorSpec: VendorSpec{
{
SortOrder: 10,
VendorPartnumber: "PN-1",
Quantity: 1,
},
},
}
baseFingerprint, err := BuildConfigurationSpecPriceFingerprint(base)
if err != nil {
t.Fatalf("base fingerprint: %v", err)
}
changedPricelist := *base
newEstimateID := uint(44)
changedPricelist.PricelistID = &newEstimateID
pricelistFingerprint, err := BuildConfigurationSpecPriceFingerprint(&changedPricelist)
if err != nil {
t.Fatalf("pricelist fingerprint: %v", err)
}
if pricelistFingerprint == baseFingerprint {
t.Fatalf("expected pricelist selector to affect fingerprint")
}
changedVendorSpec := *base
changedVendorSpec.VendorSpec = VendorSpec{
{
SortOrder: 10,
VendorPartnumber: "PN-2",
Quantity: 1,
},
}
vendorFingerprint, err := BuildConfigurationSpecPriceFingerprint(&changedVendorSpec)
if err != nil {
t.Fatalf("vendor fingerprint: %v", err)
}
if vendorFingerprint == baseFingerprint {
t.Fatalf("expected vendor spec to affect fingerprint")
}
}

View File

@@ -1066,6 +1066,26 @@ func (l *LocalDB) GetLastSyncTime() *time.Time {
return &t
}
func (l *LocalDB) getAppSettingValue(key string) (string, bool) {
var setting struct {
Value string
}
if err := l.db.Table("app_settings").
Where("key = ?", key).
First(&setting).Error; err != nil {
return "", false
}
return setting.Value, true
}
func (l *LocalDB) upsertAppSetting(tx *gorm.DB, key, value string, updatedAt time.Time) error {
return tx.Exec(`
INSERT INTO app_settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`, key, value, updatedAt.Format(time.RFC3339)).Error
}
// SetLastSyncTime sets the last sync timestamp
func (l *LocalDB) SetLastSyncTime(t time.Time) error {
// Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions
@@ -1076,6 +1096,55 @@ func (l *LocalDB) SetLastSyncTime(t time.Time) error {
`, "last_pricelist_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
}
func (l *LocalDB) GetLastPricelistSyncAttemptAt() *time.Time {
value, ok := l.getAppSettingValue("last_pricelist_sync_attempt_at")
if !ok {
return nil
}
t, err := time.Parse(time.RFC3339, value)
if err != nil {
return nil
}
return &t
}
func (l *LocalDB) GetLastPricelistSyncStatus() string {
value, ok := l.getAppSettingValue("last_pricelist_sync_status")
if !ok {
return ""
}
return strings.TrimSpace(value)
}
func (l *LocalDB) GetLastPricelistSyncError() string {
value, ok := l.getAppSettingValue("last_pricelist_sync_error")
if !ok {
return ""
}
return strings.TrimSpace(value)
}
func (l *LocalDB) SetPricelistSyncResult(status, errorText string, attemptedAt time.Time) error {
status = strings.TrimSpace(status)
errorText = strings.TrimSpace(errorText)
if status == "" {
status = "unknown"
}
return l.db.Transaction(func(tx *gorm.DB) error {
if err := l.upsertAppSetting(tx, "last_pricelist_sync_status", status, attemptedAt); err != nil {
return err
}
if err := l.upsertAppSetting(tx, "last_pricelist_sync_error", errorText, attemptedAt); err != nil {
return err
}
if err := l.upsertAppSetting(tx, "last_pricelist_sync_attempt_at", attemptedAt.Format(time.RFC3339), attemptedAt); err != nil {
return err
}
return nil
})
}
// CountLocalPricelists returns the number of local pricelists
func (l *LocalDB) CountLocalPricelists() int64 {
var count int64

View File

@@ -112,10 +112,16 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
}
type configurationSpecPriceFingerprint struct {
Items []configurationSpecPriceFingerprintItem `json:"items"`
ServerCount int `json:"server_count"`
TotalPrice *float64 `json:"total_price,omitempty"`
CustomPrice *float64 `json:"custom_price,omitempty"`
Items []configurationSpecPriceFingerprintItem `json:"items"`
ServerCount int `json:"server_count"`
TotalPrice *float64 `json:"total_price,omitempty"`
CustomPrice *float64 `json:"custom_price,omitempty"`
PricelistID *uint `json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
DisablePriceRefresh bool `json:"disable_price_refresh"`
OnlyInStock bool `json:"only_in_stock"`
VendorSpec VendorSpec `json:"vendor_spec,omitempty"`
}
type configurationSpecPriceFingerprintItem struct {
@@ -146,10 +152,16 @@ func BuildConfigurationSpecPriceFingerprint(localCfg *LocalConfiguration) (strin
})
payload := configurationSpecPriceFingerprint{
Items: items,
ServerCount: localCfg.ServerCount,
TotalPrice: localCfg.TotalPrice,
CustomPrice: localCfg.CustomPrice,
Items: items,
ServerCount: localCfg.ServerCount,
TotalPrice: localCfg.TotalPrice,
CustomPrice: localCfg.CustomPrice,
PricelistID: localCfg.PricelistID,
WarehousePricelistID: localCfg.WarehousePricelistID,
CompetitorPricelistID: localCfg.CompetitorPricelistID,
DisablePriceRefresh: localCfg.DisablePriceRefresh,
OnlyInStock: localCfg.OnlyInStock,
VendorSpec: localCfg.VendorSpec,
}
raw, err := json.Marshal(payload)

View File

@@ -1205,21 +1205,55 @@ func hasNonRevisionConfigurationChanges(current *localdb.LocalConfiguration, nex
current.ServerModel != next.ServerModel ||
current.SupportCode != next.SupportCode ||
current.Article != next.Article ||
current.DisablePriceRefresh != next.DisablePriceRefresh ||
current.OnlyInStock != next.OnlyInStock ||
current.IsActive != next.IsActive ||
current.Line != next.Line {
return true
}
if !equalUintPtr(current.PricelistID, next.PricelistID) ||
!equalUintPtr(current.WarehousePricelistID, next.WarehousePricelistID) ||
!equalUintPtr(current.CompetitorPricelistID, next.CompetitorPricelistID) ||
!equalStringPtr(current.ProjectUUID, next.ProjectUUID) {
if !equalStringPtr(current.ProjectUUID, next.ProjectUUID) {
return true
}
return false
}
func (s *LocalConfigurationService) UpdateVendorSpecNoAuth(uuid string, spec localdb.VendorSpec) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
localCfg.VendorSpec = spec
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
if err != nil {
return nil, fmt.Errorf("update vendor spec without auth with version: %w", err)
}
return cfg, nil
}
func (s *LocalConfigurationService) ApplyVendorSpecItemsNoAuth(uuid string, items localdb.LocalConfigItems) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
localCfg.Items = items
total := items.Total()
if localCfg.ServerCount > 1 {
total *= float64(localCfg.ServerCount)
}
localCfg.TotalPrice = &total
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
if err != nil {
return nil, fmt.Errorf("apply vendor spec items without auth with version: %w", err)
}
return cfg, nil
}
func equalStringPtr(a, b *string) bool {
if a == nil && b == nil {
return true

View File

@@ -137,6 +137,77 @@ func TestUpdateNoAuthSkipsRevisionWhenSpecAndPriceUnchanged(t *testing.T) {
}
}
func TestUpdateNoAuthCreatesRevisionWhenPricingSettingsChanged(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
created, err := service.Create("tester", &CreateConfigRequest{
Name: "pricing",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
Name: "pricing",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
ServerCount: 1,
DisablePriceRefresh: true,
OnlyInStock: true,
}); err != nil {
t.Fatalf("update pricing settings: %v", err)
}
versions := loadVersions(t, local, created.UUID)
if len(versions) != 2 {
t.Fatalf("expected 2 versions after pricing settings change, got %d", len(versions))
}
if versions[1].VersionNo != 2 {
t.Fatalf("expected latest version_no=2, got %d", versions[1].VersionNo)
}
}
func TestUpdateVendorSpecNoAuthCreatesRevision(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
created, err := service.Create("tester", &CreateConfigRequest{
Name: "bom",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
spec := localdb.VendorSpec{
{
VendorPartnumber: "PN-001",
Quantity: 2,
SortOrder: 10,
LotMappings: []localdb.VendorSpecLotMapping{
{LotName: "CPU_A", QuantityPerPN: 1},
},
},
}
if _, err := service.UpdateVendorSpecNoAuth(created.UUID, spec); err != nil {
t.Fatalf("update vendor spec: %v", err)
}
versions := loadVersions(t, local, created.UUID)
if len(versions) != 2 {
t.Fatalf("expected 2 versions after vendor spec change, got %d", len(versions))
}
cfg, err := local.GetConfigurationByUUID(created.UUID)
if err != nil {
t.Fatalf("load config after vendor spec update: %v", err)
}
if len(cfg.VendorSpec) != 1 || cfg.VendorSpec[0].VendorPartnumber != "PN-001" {
t.Fatalf("expected saved vendor spec, got %+v", cfg.VendorSpec)
}
}
func TestReorderProjectConfigurationsDoesNotCreateNewVersions(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)

View File

@@ -45,10 +45,15 @@ func NewServiceWithDB(mariaDB *gorm.DB, localDB *localdb.LocalDB) *Service {
// SyncStatus represents the current sync status
type SyncStatus struct {
LastSyncAt *time.Time `json:"last_sync_at"`
ServerPricelists int `json:"server_pricelists"`
LocalPricelists int `json:"local_pricelists"`
NeedsSync bool `json:"needs_sync"`
LastSyncAt *time.Time `json:"last_sync_at"`
LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"`
LastSyncStatus string `json:"last_sync_status,omitempty"`
LastSyncError string `json:"last_sync_error,omitempty"`
ServerPricelists int `json:"server_pricelists"`
LocalPricelists int `json:"local_pricelists"`
NeedsSync bool `json:"needs_sync"`
IncompleteServerSync bool `json:"incomplete_server_sync"`
KnownServerChangesMiss bool `json:"known_server_changes_missing"`
}
type UserSyncStatus struct {
@@ -240,30 +245,23 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
// GetStatus returns the current sync status
func (s *Service) GetStatus() (*SyncStatus, error) {
lastSync := s.localDB.GetLastSyncTime()
// Count server pricelists (only if already connected, don't reconnect)
serverCount := 0
connStatus := s.getConnectionStatus()
if connStatus.IsConnected {
if mariaDB, err := s.getDB(); err == nil && mariaDB != nil {
pricelistRepo := repository.NewPricelistRepository(mariaDB)
activeCount, err := pricelistRepo.CountActive()
if err == nil {
serverCount = int(activeCount)
}
}
}
// Count local pricelists
lastAttempt := s.localDB.GetLastPricelistSyncAttemptAt()
lastSyncStatus := s.localDB.GetLastPricelistSyncStatus()
lastSyncError := s.localDB.GetLastPricelistSyncError()
localCount := s.localDB.CountLocalPricelists()
needsSync, _ := s.NeedSync()
hasFailedSync := strings.EqualFold(lastSyncStatus, "failed")
needsSync := lastSync == nil || hasFailedSync
return &SyncStatus{
LastSyncAt: lastSync,
ServerPricelists: serverCount,
LocalPricelists: int(localCount),
NeedsSync: needsSync,
LastSyncAt: lastSync,
LastAttemptAt: lastAttempt,
LastSyncStatus: lastSyncStatus,
LastSyncError: lastSyncError,
ServerPricelists: 0,
LocalPricelists: int(localCount),
NeedsSync: needsSync,
IncompleteServerSync: hasFailedSync,
KnownServerChangesMiss: hasFailedSync,
}, nil
}
@@ -333,6 +331,7 @@ func (s *Service) SyncPricelists() (int, error) {
// Get database connection
mariaDB, err := s.getDB()
if err != nil {
s.recordPricelistSyncFailure(err)
return 0, fmt.Errorf("database not available: %w", err)
}
@@ -342,6 +341,7 @@ func (s *Service) SyncPricelists() (int, error) {
// Get active pricelists from server (up to 100)
serverPricelists, _, err := pricelistRepo.ListActive(0, 100)
if err != nil {
s.recordPricelistSyncFailure(err)
return 0, fmt.Errorf("getting active server pricelists: %w", err)
}
serverPricelistIDs := make([]uint, 0, len(serverPricelists))
@@ -350,6 +350,7 @@ func (s *Service) SyncPricelists() (int, error) {
}
synced := 0
var syncErr error
for _, pl := range serverPricelists {
// Check if pricelist already exists locally
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
@@ -358,6 +359,9 @@ func (s *Service) SyncPricelists() (int, error) {
if s.localDB.CountLocalPricelistItems(existing.ID) == 0 {
itemCount, err := s.SyncPricelistItems(existing.ID)
if err != nil {
if syncErr == nil {
syncErr = fmt.Errorf("sync items for existing pricelist %s: %w", pl.Version, err)
}
slog.Warn("failed to sync missing pricelist items for existing local pricelist", "version", pl.Version, "error", err)
} else {
slog.Info("synced missing pricelist items for existing local pricelist", "version", pl.Version, "items", itemCount)
@@ -377,19 +381,15 @@ func (s *Service) SyncPricelists() (int, error) {
IsUsed: false,
}
if err := s.localDB.SaveLocalPricelist(localPL); err != nil {
slog.Warn("failed to save local pricelist", "version", pl.Version, "error", err)
itemCount, err := s.syncNewPricelistSnapshot(localPL)
if err != nil {
if syncErr == nil {
syncErr = fmt.Errorf("sync new pricelist %s: %w", pl.Version, err)
}
slog.Warn("failed to sync pricelist snapshot", "version", pl.Version, "error", err)
continue
}
// Sync items for the newly created pricelist
itemCount, err := s.SyncPricelistItems(localPL.ID)
if err != nil {
slog.Warn("failed to sync pricelist items", "version", pl.Version, "error", err)
// Continue even if items sync fails - we have the pricelist metadata
} else {
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
}
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
synced++
}
@@ -404,14 +404,96 @@ func (s *Service) SyncPricelists() (int, error) {
// Backfill lot_category for used pricelists (older local caches may miss the column values).
s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
if syncErr != nil {
s.recordPricelistSyncFailure(syncErr)
return synced, syncErr
}
// Update last sync time
s.localDB.SetLastSyncTime(time.Now())
now := time.Now()
s.localDB.SetLastSyncTime(now)
s.recordPricelistSyncSuccess(now)
s.RecordSyncHeartbeat()
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
return synced, nil
}
func (s *Service) recordPricelistSyncSuccess(at time.Time) {
if s.localDB == nil {
return
}
if err := s.localDB.SetPricelistSyncResult("success", "", at); err != nil {
slog.Warn("failed to persist pricelist sync success state", "error", err)
}
}
func (s *Service) recordPricelistSyncFailure(syncErr error) {
if s.localDB == nil || syncErr == nil {
return
}
s.markConnectionBroken(syncErr)
if err := s.localDB.SetPricelistSyncResult("failed", syncErr.Error(), time.Now()); err != nil {
slog.Warn("failed to persist pricelist sync failure state", "error", err)
}
}
func (s *Service) markConnectionBroken(err error) {
if err == nil || s.connMgr == nil {
return
}
msg := strings.ToLower(err.Error())
switch {
case strings.Contains(msg, "i/o timeout"),
strings.Contains(msg, "invalid connection"),
strings.Contains(msg, "bad connection"),
strings.Contains(msg, "connection reset"),
strings.Contains(msg, "broken pipe"),
strings.Contains(msg, "unexpected eof"):
s.connMgr.MarkOffline(err)
}
}
func (s *Service) syncNewPricelistSnapshot(localPL *localdb.LocalPricelist) (int, error) {
if localPL == nil {
return 0, fmt.Errorf("local pricelist is nil")
}
localItems, err := s.fetchServerPricelistItems(localPL.ServerID)
if err != nil {
return 0, err
}
if err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
if err := tx.Create(localPL).Error; err != nil {
return fmt.Errorf("save local pricelist: %w", err)
}
if len(localItems) == 0 {
return nil
}
for i := range localItems {
localItems[i].PricelistID = localPL.ID
}
batchSize := 500
for i := 0; i < len(localItems); i += batchSize {
end := i + batchSize
if end > len(localItems) {
end = len(localItems)
}
if err := tx.CreateInBatches(localItems[i:end], batchSize).Error; err != nil {
return fmt.Errorf("save local pricelist items: %w", err)
}
}
return nil
}); err != nil {
return 0, err
}
slog.Info("synced pricelist items", "pricelist_id", localPL.ID, "items", len(localItems))
return len(localItems), nil
}
func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.PricelistRepository, activeServerPricelistIDs []uint) {
if s.localDB == nil || pricelistRepo == nil {
return
@@ -670,30 +752,13 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
return int(existingCount), nil
}
// Get database connection
mariaDB, err := s.getDB()
localItems, err := s.fetchServerPricelistItems(localPL.ServerID)
if err != nil {
return 0, fmt.Errorf("database not available: %w", err)
return 0, err
}
// Create repository
pricelistRepo := repository.NewPricelistRepository(mariaDB)
// Get items from server
serverItems, _, err := pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "")
if err != nil {
return 0, fmt.Errorf("getting server pricelist items: %w", err)
for i := range localItems {
localItems[i].PricelistID = localPricelistID
}
// Convert and save locally
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
for i, item := range serverItems {
localItems[i] = *localdb.PricelistItemToLocal(&item, localPricelistID)
}
if err := s.enrichLocalPricelistItemsWithStock(mariaDB, localItems); err != nil {
slog.Warn("pricelist stock enrichment skipped", "pricelist_id", localPricelistID, "error", err)
}
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
return 0, fmt.Errorf("saving local pricelist items: %w", err)
}
@@ -702,6 +767,33 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
return len(localItems), nil
}
func (s *Service) fetchServerPricelistItems(serverPricelistID uint) ([]localdb.LocalPricelistItem, error) {
// Get database connection
mariaDB, err := s.getDB()
if err != nil {
return nil, fmt.Errorf("database not available: %w", err)
}
// Create repository
pricelistRepo := repository.NewPricelistRepository(mariaDB)
// Get items from server
serverItems, _, err := pricelistRepo.GetItems(serverPricelistID, 0, 10000, "")
if err != nil {
return nil, fmt.Errorf("getting server pricelist items: %w", err)
}
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
for i, item := range serverItems {
localItems[i] = *localdb.PricelistItemToLocal(&item, 0)
}
if err := s.enrichLocalPricelistItemsWithStock(mariaDB, localItems); err != nil {
slog.Warn("pricelist stock enrichment skipped", "server_pricelist_id", serverPricelistID, "error", err)
}
return localItems, nil
}
// SyncPricelistItemsByServerID syncs items for a pricelist by its server ID
func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, error) {
localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID)
@@ -901,6 +993,7 @@ func (s *Service) PushPendingChanges() (int, error) {
for _, change := range sortedChanges {
err := s.pushSingleChange(&change)
if err != nil {
s.markConnectionBroken(err)
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err)
// Increment attempts
s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error())

View File

@@ -1,12 +1,15 @@
package sync_test
import (
"errors"
"strings"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
"gorm.io/gorm"
)
func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) {
@@ -83,3 +86,58 @@ func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) {
t.Fatalf("expected server pricelist to be synced locally: %v", err)
}
}
func TestSyncPricelistsDoesNotPersistHeaderWithoutItems(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
if err := serverDB.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}); err != nil {
t.Fatalf("migrate server pricelist tables: %v", err)
}
serverPL := models.Pricelist{
Source: "estimate",
Version: "2026-03-17-001",
Notification: "server",
CreatedBy: "tester",
IsActive: true,
CreatedAt: time.Now().Add(-1 * time.Hour),
}
if err := serverDB.Create(&serverPL).Error; err != nil {
t.Fatalf("create server pricelist: %v", err)
}
if err := serverDB.Create(&models.PricelistItem{PricelistID: serverPL.ID, LotName: "CPU_A", Price: 10}).Error; err != nil {
t.Fatalf("create server pricelist item: %v", err)
}
const callbackName = "test:fail_qt_pricelist_items_query"
if err := serverDB.Callback().Query().Before("gorm:query").Register(callbackName, func(db *gorm.DB) {
if db.Statement != nil && db.Statement.Table == "qt_pricelist_items" {
_ = db.AddError(errors.New("forced pricelist item fetch failure"))
}
}); err != nil {
t.Fatalf("register query callback: %v", err)
}
defer serverDB.Callback().Query().Remove(callbackName)
svc := syncsvc.NewServiceWithDB(serverDB, local)
synced, err := svc.SyncPricelists()
if err == nil {
t.Fatalf("expected sync error when item fetch fails")
}
if synced != 0 {
t.Fatalf("expected synced=0 on incomplete sync, got %d", synced)
}
if !strings.Contains(err.Error(), "forced pricelist item fetch failure") {
t.Fatalf("expected item fetch error, got %v", err)
}
if _, err := local.GetLocalPricelistByServerID(serverPL.ID); err == nil {
t.Fatalf("expected pricelist header not to be persisted without items")
}
if got := local.CountLocalPricelists(); got != 0 {
t.Fatalf("expected no local pricelists after failed sync, got %d", got)
}
if ts := local.GetLastSyncTime(); ts != nil {
t.Fatalf("expected last_pricelist_sync to stay unset on incomplete sync, got %v", ts)
}
}

View File

@@ -3,16 +3,48 @@
Дата релиза: 2026-03-16
Тег: `v1.5.4`
Предыдущий релиз: `v1.5.0`
## Ключевые изменения
- runtime автоматически нормализует `server.host` к `127.0.0.1` и переписывает некорректный локальный конфиг;
- pricing tab переработан: закупка и продажа разделены на отдельные таблицы с ценами за 1 шт.;
- экран прайслиста переработан под разные типы источников; удалены misleading-колонки `Поставщик` и `partnumbers`;
- runtime и startup ужесточены: локальный клиент принудительно работает только на loopback, конфиг автоматически нормализуется;
- добавлены действия с вариантом и унифицированы правила именования `_копия` для вариантов и конфигураций;
- исправлен CSV-экспорт прайсинговых таблиц в конфигураторе под Excel-совместимый формат;
- таблица проектов переработана: новая колонка даты, tooltip с деталями, отдельный автор, компактные действия и ссылка на трекер;
- исправлен CSV-экспорт прайсинговых таблиц в конфигураторе под Excel-совместимый формат Excel-friendly;
- таблица проектов переработана: дата последней правки, tooltip с деталями, отдельный автор, компактные действия и ссылка на трекер;
- sync больше не подменяет `updated_at` проектов временем синхронизации;
- добавлена одноразовая утилита `cmd/migrate_project_updated_at` для пересинхронизации `updated_at` проектов из MariaDB в локальную SQLite.
- добавлена одноразовая утилита `cmd/migrate_project_updated_at` для пересинхронизации `updated_at` проектов из MariaDB в локальную SQLite;
- runtime config, release notes и `bible-local/` очищены и приведены к актуальной архитектуре;
- `scripts/release.sh` больше не затирает существующий `RELEASE_NOTES.md`.
## Summary
### UI и UX
- вкладка ценообразования теперь разделена на отдельные таблицы закупки и продажи;
- список проектов переработан: новая колонка даты, отдельный автор, tooltip с деталями, компактные действия, ссылка на трекер;
- для вариантов добавлены действия переименования, переноса и копирования;
- копии вариантов и конфигураций теперь именуются единообразно: `_копия`, `_копия2`, `_копия3`.
### Прайслисты и экспорт
- экран прайслиста переработан под разные типы источников;
- из прайслистов убраны misleading-колонки `Поставщик` и `partnumbers`;
- CSV-экспорт прайсинговых таблиц в конфигураторе приведён к Excel-совместимому формату.
### Runtime и sync
- локальный runtime нормализует `server.host` к `127.0.0.1` и переписывает некорректный runtime config;
- sync перестал подменять `updated_at` проектов временем локальной синхронизации;
- добавлена утилита `cmd/migrate_project_updated_at` для восстановления локальных дат проектов с сервера.
### Документация и release tooling
- `bible-local/` сокращён до актуальных архитектурных контрактов;
- release notes и release-структура приведены к одному формату;
- `scripts/release.sh` теперь сохраняет существующий `RELEASE_NOTES.md` и не затирает его шаблоном.
## Затронутые области
- `cmd/qfs/`;
@@ -20,6 +52,8 @@
- `internal/localdb/`;
- `internal/services/project.go`;
- `internal/services/sync/service.go`;
- `internal/handlers/pricelist.go`;
- `web/templates/pricelist_detail.html`;
- `web/templates/index.html`;
- `web/templates/project_detail.html`;
- `web/templates/projects.html`;

1
web/static/vendor/htmx-1.9.10.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,8 +5,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{template "title" .}}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<link rel="stylesheet" href="/static/app.css">
<script src="/static/vendor/tailwindcss.browser.js"></script>
<script src="/static/vendor/htmx-1.9.10.min.js"></script>
<style>
.htmx-request { opacity: 0.5; }
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
@@ -92,6 +93,15 @@
</div>
</div>
<div id="modal-pricelist-sync-issue" class="hidden">
<h4 class="font-medium text-red-700 mb-2">Состояние прайслистов</h4>
<div class="bg-red-50 border border-red-200 rounded px-3 py-2 text-sm space-y-1">
<div id="modal-pricelist-sync-summary" class="text-red-700"></div>
<div id="modal-pricelist-sync-attempt" class="text-red-600 text-xs hidden"></div>
<div id="modal-pricelist-sync-error" class="text-red-600 text-xs hidden whitespace-pre-wrap"></div>
</div>
</div>
<!-- Section 2: Statistics -->
<div>
<h4 class="font-medium text-gray-900 mb-2">Статистика</h4>
@@ -229,6 +239,43 @@
readinessMinVersion.textContent = '';
}
const syncIssueSection = document.getElementById('modal-pricelist-sync-issue');
const syncIssueSummary = document.getElementById('modal-pricelist-sync-summary');
const syncIssueAttempt = document.getElementById('modal-pricelist-sync-attempt');
const syncIssueError = document.getElementById('modal-pricelist-sync-error');
const hasSyncFailure = data.last_pricelist_sync_status === 'failed';
if (data.has_incomplete_server_sync) {
syncIssueSection.classList.remove('hidden');
syncIssueSummary.textContent = 'Последняя синхронизация прайслистов прервалась. На сервере есть изменения, которые еще не загружены локально.';
} else if (hasSyncFailure) {
syncIssueSection.classList.remove('hidden');
syncIssueSummary.textContent = 'Последняя синхронизация прайслистов завершилась ошибкой.';
} else {
syncIssueSection.classList.add('hidden');
syncIssueSummary.textContent = '';
}
if (syncIssueSection.classList.contains('hidden')) {
syncIssueAttempt.classList.add('hidden');
syncIssueAttempt.textContent = '';
syncIssueError.classList.add('hidden');
syncIssueError.textContent = '';
} else {
if (data.last_pricelist_attempt_at) {
syncIssueAttempt.classList.remove('hidden');
syncIssueAttempt.textContent = 'Последняя попытка: ' + new Date(data.last_pricelist_attempt_at).toLocaleString('ru-RU');
} else {
syncIssueAttempt.classList.add('hidden');
syncIssueAttempt.textContent = '';
}
if (data.last_pricelist_sync_error) {
syncIssueError.classList.remove('hidden');
syncIssueError.textContent = data.last_pricelist_sync_error;
} else {
syncIssueError.classList.add('hidden');
syncIssueError.textContent = '';
}
}
// Section 2: Statistics
document.getElementById('modal-lot-count').textContent = data.is_online ? data.lot_count.toLocaleString() : '—';
document.getElementById('modal-lotlog-count').textContent = data.is_online ? data.lot_log_count.toLocaleString() : '—';

View File

@@ -135,15 +135,18 @@ async function loadVersions() {
}
function renderVersions(versions) {
if (versions.length === 0) {
const currentVersionNo = configData && configData.current_version_no ? Number(configData.current_version_no) : null;
const snapshots = versions.filter(v => Number(v.version_no) !== currentVersionNo);
if (snapshots.length === 0) {
document.getElementById('revisions-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">Нет ревизий</div>';
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">Нет прошлых снимков. Рабочая версия остается main.</div>';
return;
}
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
html += '<thead class="bg-gray-50"><tr>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Снимок</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>';
@@ -152,16 +155,14 @@ function renderVersions(versions) {
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
html += '</tr></thead><tbody class="divide-y">';
versions.forEach((v, idx) => {
snapshots.forEach((v) => {
const date = new Date(v.created_at).toLocaleString('ru-RU');
const author = v.created_by || '—';
const snapshot = parseVersionSnapshot(v);
const isCurrent = idx === 0;
html += '<tr class="hover:bg-gray-50' + (isCurrent ? ' bg-blue-50' : '') + '">';
html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-4 py-3 text-sm font-medium">';
html += 'v' + v.version_no;
if (isCurrent) html += ' <span class="text-xs text-blue-600 font-normal">(текущая)</span>';
html += '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(date) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
@@ -178,11 +179,8 @@ function renderVersions(versions) {
html += '<button onclick="cloneFromVersion(' + v.version_no + ')" class="text-green-600 hover:text-green-800" title="Скопировать из этой ревизии">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg></button>';
// Rollback (not for current version)
if (!isCurrent) {
html += '<button onclick="rollbackToVersion(' + v.version_no + ')" class="text-orange-600 hover:text-orange-800" title="Восстановить эту ревизию">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"></path></svg></button>';
}
html += '<button onclick="rollbackToVersion(' + v.version_no + ')" class="text-orange-600 hover:text-orange-800" title="Восстановить этот снимок как новую main-версию">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"></path></svg></button>';
html += '</td></tr>';
});
@@ -212,7 +210,7 @@ async function cloneFromVersion(versionNo) {
}
async function rollbackToVersion(versionNo) {
if (!confirm('Восстановить конфигурацию до ревизии v' + versionNo + '?')) return;
if (!confirm('Восстановить снимок v' + versionNo + ' как новую рабочую версию main?')) return;
const resp = await fetch('/api/configs/' + configUUID + '/rollback', {
method: 'POST',
headers: {'Content-Type': 'application/json'},

View File

@@ -24,7 +24,7 @@
<span id="breadcrumb-config-name">Конфигуратор</span>
</a>
<span class="text-gray-400">-</span>
<span id="breadcrumb-config-version">v1</span>
<span id="breadcrumb-config-version">main</span>
</div>
</div>
<div id="save-buttons" class="hidden flex items-center space-x-2">
@@ -211,9 +211,9 @@
<table class="w-full text-sm border-collapse">
<thead class="bg-gray-50 text-gray-700">
<tr>
<th class="px-3 py-2 text-left border-b">LOT</th>
<th class="px-3 py-2 text-left border-b">PN вендора</th>
<th class="px-3 py-2 text-left border-b">Описание</th>
<th class="px-3 py-2 text-left border-b">LOT</th>
<th class="px-3 py-2 text-right border-b">Кол-во</th>
<th class="px-3 py-2 text-right border-b">Estimate</th>
<th class="px-3 py-2 text-right border-b">Склад</th>
@@ -236,12 +236,12 @@
</table>
</div>
<div class="mt-4 flex flex-wrap items-center gap-4">
<label class="text-sm font-medium text-gray-700">Своя цена:</label>
<label class="text-sm font-medium text-gray-700">Ручная цена:</label>
<input type="number" id="pricing-custom-price-buy" step="0.01" min="0" placeholder="0.00"
class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="onBuyCustomPriceInput()">
<button onclick="setPricingCustomPriceFromVendor()" class="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 border border-gray-300 text-sm">
Проставить цены BOM
BOM Цена
</button>
<button onclick="exportPricingCSV('buy')" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">
Экспорт CSV
@@ -260,9 +260,9 @@
<table class="w-full text-sm border-collapse">
<thead class="bg-gray-50 text-gray-700">
<tr>
<th class="px-3 py-2 text-left border-b">LOT</th>
<th class="px-3 py-2 text-left border-b">PN вендора</th>
<th class="px-3 py-2 text-left border-b">Описание</th>
<th class="px-3 py-2 text-left border-b">LOT</th>
<th class="px-3 py-2 text-right border-b">Кол-во</th>
<th class="px-3 py-2 text-right border-b">Estimate</th>
<th class="px-3 py-2 text-right border-b">Склад</th>
@@ -289,7 +289,7 @@
<input type="text" id="pricing-uplift-sale" inputmode="decimal" placeholder="1,3000"
class="w-28 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="onSaleMarkupInput()">
<label class="text-sm font-medium text-gray-700">Своя цена:</label>
<label class="text-sm font-medium text-gray-700">Ручная цена:</label>
<input type="number" id="pricing-custom-price-sale" step="0.01" min="0" placeholder="0.00"
class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="onSaleCustomPriceInput()">
@@ -476,7 +476,7 @@ function updateConfigBreadcrumbs() {
const fullConfigName = configName || 'Конфигурация';
configEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
configEl.title = fullConfigName;
versionEl.textContent = 'v' + (currentVersionNo || 1);
versionEl.textContent = 'main';
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
if (configNameLinkEl && configUUID) {
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
@@ -2172,9 +2172,8 @@ async function saveConfig(showNotification = true) {
const saved = await resp.json();
if (saved && saved.current_version_no) {
currentVersionNo = saved.current_version_no;
const versionEl = document.getElementById('breadcrumb-config-version');
if (versionEl) versionEl.textContent = 'v' + currentVersionNo;
}
updateConfigBreadcrumbs();
hasUnsavedChanges = false;
clearAutosaveDraft();
exitSaveStarted = false;
@@ -2558,7 +2557,7 @@ async function refreshPrices() {
}
// Re-render UI
await refreshPriceLevels();
await refreshPriceLevels({ force: true, noCache: true });
renderTab();
updateCartUI();
@@ -3610,6 +3609,7 @@ async function renderPricingTab() {
});
// ─── Build shared row data (unit prices for display, totals for math) ────
// Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize.
const _buildRows = () => {
const result = [];
const coveredLots = new Set();
@@ -3619,7 +3619,8 @@ async function renderPricingTab() {
const u = _getUnitPrices(pl);
const estUnit = u.estUnit > 0 ? u.estUnit : (item.unit_price || 0);
result.push({
lotCell: escapeHtml(item.lot_name), vendorPN: null,
lotCell: escapeHtml(item.lot_name), lotText: item.lot_name,
vendorPN: null,
desc: (compMap[item.lot_name] || {}).description || '',
qty: item.quantity,
estUnit, warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
@@ -3627,6 +3628,7 @@ async function renderPricingTab() {
warehouse: u.warehouseUnit != null ? u.warehouseUnit * item.quantity : null,
competitor: u.competitorUnit != null ? u.competitorUnit * item.quantity : null,
vendorOrig: null, vendorOrigUnit: null, isEstOnly,
groupStart: true, groupSize: 1,
});
};
@@ -3641,42 +3643,66 @@ async function renderPricingTab() {
if (baseLot) coveredLots.add(baseLot);
allocs.forEach(a => coveredLots.add(a.lot_name));
// Accumulate unit prices per 1 vendor PN (base + allocs)
let rowEstUnit = 0, rowWhUnit = 0, rowCompUnit = 0;
let hasEst = false, hasWh = false, hasComp = false;
if (baseLot) {
const u = _getUnitPrices(priceMap[baseLot]);
const lotQty = _getRowLotQtyPerPN(row);
if (u.estUnit > 0) { rowEstUnit += u.estUnit * lotQty; hasEst = true; }
if (u.warehouseUnit != null) { rowWhUnit += u.warehouseUnit * lotQty; hasWh = true; }
if (u.competitorUnit != null) { rowCompUnit += u.competitorUnit * lotQty; hasComp = true; }
}
allocs.forEach(a => {
const u = _getUnitPrices(priceMap[a.lot_name]);
if (u.estUnit > 0) { rowEstUnit += u.estUnit * a.quantity; hasEst = true; }
if (u.warehouseUnit != null) { rowWhUnit += u.warehouseUnit * a.quantity; hasWh = true; }
if (u.competitorUnit != null) { rowCompUnit += u.competitorUnit * a.quantity; hasComp = true; }
});
let lotCell = '<span class="text-red-500">н/д</span>';
if (baseLot && allocs.length) lotCell = `${escapeHtml(baseLot)} <span class="text-gray-400">+${allocs.length}</span>`;
else if (baseLot) lotCell = escapeHtml(baseLot);
else if (allocs.length) lotCell = `${escapeHtml(allocs[0].lot_name)}${allocs.length > 1 ? ` <span class="text-gray-400">+${allocs.length - 1}</span>` : ''}`;
const vendorOrigUnit = row.unit_price != null ? row.unit_price
: (row.total_price != null && row.quantity > 0 ? row.total_price / row.quantity : null);
const vendorOrig = row.total_price != null ? row.total_price
: (row.unit_price != null ? row.unit_price * row.quantity : null);
const desc = row.description || (baseLot ? ((compMap[baseLot] || {}).description || '') : '');
result.push({
lotCell, vendorPN: row.vendor_pn, desc, qty: row.quantity,
estUnit: hasEst ? rowEstUnit : 0,
warehouseUnit: hasWh ? rowWhUnit : null,
competitorUnit: hasComp ? rowCompUnit : null,
est: hasEst ? rowEstUnit * row.quantity : 0,
warehouse: hasWh ? rowWhUnit * row.quantity : null,
competitor: hasComp ? rowCompUnit * row.quantity : null,
vendorOrig, vendorOrigUnit, isEstOnly: false,
// Build per-LOT sub-rows
const subRows = [];
if (baseLot) {
const u = _getUnitPrices(priceMap[baseLot]);
const lotQty = _getRowLotQtyPerPN(row);
const qty = row.quantity * lotQty;
subRows.push({
lotCell: escapeHtml(baseLot), lotText: baseLot, qty,
estUnit: u.estUnit > 0 ? u.estUnit : 0,
warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
est: u.estUnit > 0 ? u.estUnit * qty : 0,
warehouse: u.warehouseUnit != null ? u.warehouseUnit * qty : null,
competitor: u.competitorUnit != null ? u.competitorUnit * qty : null,
});
}
allocs.forEach(a => {
const u = _getUnitPrices(priceMap[a.lot_name]);
const qty = row.quantity * a.quantity;
subRows.push({
lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty,
estUnit: u.estUnit > 0 ? u.estUnit : 0,
warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
est: u.estUnit > 0 ? u.estUnit * qty : 0,
warehouse: u.warehouseUnit != null ? u.warehouseUnit * qty : null,
competitor: u.competitorUnit != null ? u.competitorUnit * qty : null,
});
});
if (!subRows.length) {
result.push({
lotCell: '<span class="text-red-500">н/д</span>', lotText: '',
vendorPN: row.vendor_pn, desc, qty: row.quantity,
estUnit: 0, warehouseUnit: null, competitorUnit: null,
est: 0, warehouse: null, competitor: null,
vendorOrig, vendorOrigUnit, isEstOnly: false,
groupStart: true, groupSize: 1,
});
return;
}
const groupSize = subRows.length;
subRows.forEach((sub, idx) => {
result.push({
lotCell: sub.lotCell, lotText: sub.lotText,
vendorPN: row.vendor_pn, desc,
qty: sub.qty,
estUnit: sub.estUnit, warehouseUnit: sub.warehouseUnit, competitorUnit: sub.competitorUnit,
est: sub.est, warehouse: sub.warehouse, competitor: sub.competitor,
vendorOrig: idx === 0 ? vendorOrig : null,
vendorOrigUnit: idx === 0 ? vendorOrigUnit : null,
isEstOnly: false,
groupStart: idx === 0,
groupSize: idx === 0 ? groupSize : 0,
});
});
});
@@ -3709,19 +3735,28 @@ async function renderPricingTab() {
tr.dataset.qty = r.qty;
tr.dataset.vendorOrig = r.vendorOrig != null ? r.vendorOrig : '';
tr.dataset.vendorOrigUnit = r.vendorOrigUnit != null ? r.vendorOrigUnit : '';
tr.dataset.groupStart = r.groupStart ? 'true' : 'false';
tr.dataset.vendorPn = r.vendorPN || '';
tr.dataset.desc = r.desc;
tr.dataset.lot = r.lotText;
if (r.est > 0) { totEst += r.est; hasEst = true; }
if (r.warehouse != null) { totWh += r.warehouse; hasWh = true; cntWh++; }
if (r.competitor != null) { totComp += r.competitor; hasComp = true; cntComp++; }
if (r.vendorOrig != null) { totVendor += r.vendorOrig; hasVendor = true; }
const borderTop = r.groupStart ? 'border-t border-gray-200' : '';
const pnDescHtml = r.groupStart ? (() => {
const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : '';
return `<td${rs} class="px-3 py-1.5 font-mono text-xs border-t border-gray-200 align-top ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
<td${rs} class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs border-t border-gray-200 align-top">${escapeHtml(r.desc)}</td>`;
})() : '';
tr.innerHTML = `
<td class="px-3 py-1.5 text-xs">${r.lotCell}</td>
<td class="px-3 py-1.5 font-mono text-xs ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(r.desc)}</td>
<td class="px-3 py-1.5 text-right text-xs">${r.qty}</td>
<td class="px-3 py-1.5 text-right text-xs">${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs">${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs">${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-buy ${r.vendorOrigUnit == null ? 'text-gray-400' : ''}">${r.vendorOrigUnit != null ? formatCurrency(r.vendorOrigUnit) : '—'}</td>
${pnDescHtml}
<td class="px-3 py-1.5 text-xs ${borderTop}">${r.lotCell}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-buy ${borderTop} ${r.vendorOrigUnit == null ? 'text-gray-400' : ''}">${r.vendorOrigUnit != null ? formatCurrency(r.vendorOrigUnit) : '—'}</td>
`;
tbodyBuy.appendChild(tr);
});
@@ -3753,18 +3788,27 @@ async function renderPricingTab() {
const saleCompTotal = saleCompUnit != null ? saleCompUnit * r.qty : null;
tr.dataset.estSale = saleEstTotal;
tr.dataset.qty = r.qty;
tr.dataset.groupStart = r.groupStart ? 'true' : 'false';
tr.dataset.vendorPn = r.vendorPN || '';
tr.dataset.desc = r.desc;
tr.dataset.lot = r.lotText;
if (saleEstTotal > 0) { totEst += saleEstTotal; hasEst = true; }
if (saleWhTotal != null) { totWh += saleWhTotal; hasWh = true; cntWh++; }
if (saleCompTotal != null) { totComp += saleCompTotal; hasComp = true; cntComp++; }
const borderTop = r.groupStart ? 'border-t border-gray-200' : '';
const pnDescHtml = r.groupStart ? (() => {
const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : '';
return `<td${rs} class="px-3 py-1.5 font-mono text-xs border-t border-gray-200 align-top ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
<td${rs} class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs border-t border-gray-200 align-top">${escapeHtml(r.desc)}</td>`;
})() : '';
tr.innerHTML = `
<td class="px-3 py-1.5 text-xs">${r.lotCell}</td>
<td class="px-3 py-1.5 font-mono text-xs ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(r.desc)}</td>
<td class="px-3 py-1.5 text-right text-xs">${r.qty}</td>
<td class="px-3 py-1.5 text-right text-xs">${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs">${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs">${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-sale text-gray-400">—</td>
${pnDescHtml}
<td class="px-3 py-1.5 text-xs ${borderTop}">${r.lotCell}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-sale ${borderTop} text-gray-400">—</td>
`;
tbodySale.appendChild(tr);
});
@@ -3962,12 +4006,25 @@ function exportPricingCSV(table) {
return /[;"\n\r]/.test(s) ? `"${s}"` : s;
};
const headers = ['Lot', 'PN вендора', 'Описание', 'Кол-во', 'Estimate', 'Склад', 'Конкуренты', 'Ручная цена'];
const headers = ['PN вендора', 'Описание', 'LOT', 'Кол-во', 'Estimate', 'Склад', 'Конкуренты', 'Ручная цена'];
const lines = [headers.map(csvEscape).join(csvDelimiter)];
rows.forEach(tr => {
// PN вендора, Описание, LOT are stored in dataset to handle rowspan correctly
const pn = cleanExportCell(tr.dataset.vendorPn || '');
const desc = cleanExportCell(tr.dataset.desc || '');
const lot = cleanExportCell(tr.dataset.lot || '');
// Qty..Ручная цена: cells at offset 2 for group-start rows, offset 0 for sub-rows
const isGroupStart = tr.dataset.groupStart === 'true';
const cells = tr.querySelectorAll('td');
const cols = [0,1,2,3,4,5,6,7].map(i => cells[i] ? cleanExportCell(cells[i].textContent) : '');
const o = isGroupStart ? 2 : 0;
const cols = [pn, desc, lot,
cleanExportCell(cells[o]?.textContent),
cleanExportCell(cells[o+1]?.textContent),
cleanExportCell(cells[o+2]?.textContent),
cleanExportCell(cells[o+3]?.textContent),
cleanExportCell(cells[o+4]?.textContent),
];
lines.push(cols.map(csvEscape).join(csvDelimiter));
});

View File

@@ -14,7 +14,21 @@
</span>
{{end}}
{{if .IsBlocked}}
{{if .HasIncompleteServerSync}}
<span class="bg-red-100 text-red-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" title="{{.SyncIssueTitle}}" onclick="openSyncModal()">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-7.938 4h15.876c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L2.33 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
Не докачано
</span>
{{else if .HasFailedSync}}
<span class="bg-orange-100 text-orange-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" title="{{.SyncIssueTitle}}" onclick="openSyncModal()">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M4.93 19h14.14c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.2 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
Sync error
</span>
{{else if .IsBlocked}}
<span class="bg-red-100 text-red-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" title="{{.BlockedReason}}" onclick="openSyncModal()">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-7.938 4h15.876c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L2.33 16c-.77 1.333.192 3 1.732 3z"></path>

View File

@@ -472,8 +472,7 @@ function renderConfigs(configs) {
html += '<td class="px-4 py-3 text-sm text-gray-500"><input type="number" min="1" value="' + serverCount + '" class="w-16 px-1 py-0.5 border rounded text-center text-sm" data-uuid="' + c.uuid + '" data-prev="' + serverCount + '" onchange="updateConfigServerCount(this)"></td>';
}
html += '<td class="px-4 py-3 text-sm text-right" data-total-uuid="' + c.uuid + '">' + formatMoneyNoDecimals(total) + '</td>';
const versionNo = c.current_version_no || 1;
html += '<td class="px-2 py-3 text-sm text-center text-gray-500 w-12">v' + versionNo + '</td>';
html += '<td class="px-2 py-3 text-sm text-center text-gray-500 w-12">main</td>';
html += '<td class="px-4 py-3 text-sm text-right whitespace-nowrap"><div class="inline-flex items-center justify-end gap-2">';
if (configStatusMode === 'archived') {
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';

View File

@@ -5,7 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>QuoteForge - Настройка подключения</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/static/app.css">
<script src="/static/vendor/tailwindcss.browser.js"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
<div class="max-w-md w-full mx-4">