Compare commits
20 Commits
v1.2.1
...
87cb12906d
| Author | SHA1 | Date | |
|---|---|---|---|
| 87cb12906d | |||
| 075fc709dd | |||
| cbaeafa9c8 | |||
| 71f73e2f1d | |||
| 2e973b6d78 | |||
| 8508ee2921 | |||
| b153afbf51 | |||
|
|
9b5d57902d | ||
|
|
4e1a46bd71 | ||
|
|
857ec7a0e5 | ||
|
|
01f21fa5ac | ||
|
|
a1edca3be9 | ||
|
|
7fbf813952 | ||
|
|
e58fd35ee4 | ||
|
|
e3559035f7 | ||
|
|
5edffe822b | ||
|
|
99fd80bca7 | ||
|
|
d8edd5d5f0 | ||
|
|
9cb17ee03f | ||
|
|
8f596cec68 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -23,6 +23,7 @@ secrets.yml
|
|||||||
/importer
|
/importer
|
||||||
/cron
|
/cron
|
||||||
/bin/
|
/bin/
|
||||||
|
qfs
|
||||||
|
|
||||||
# Local Go build cache used in sandboxed runs
|
# Local Go build cache used in sandboxed runs
|
||||||
.gocache/
|
.gocache/
|
||||||
@@ -74,8 +75,7 @@ Network Trash Folder
|
|||||||
Temporary Items
|
Temporary Items
|
||||||
.apdisk
|
.apdisk
|
||||||
|
|
||||||
# Release artifacts, but DO track releases/memory/ for changelog
|
# Release artifacts (binaries, archives, checksums), but DO track releases/memory/ for changelog
|
||||||
releases/
|
releases/*
|
||||||
!releases/
|
|
||||||
!releases/memory/
|
!releases/memory/
|
||||||
!releases/memory/**
|
!releases/memory/**
|
||||||
|
|||||||
@@ -20,18 +20,22 @@
|
|||||||
- Local-first: чтение и запись происходят в SQLite
|
- Local-first: чтение и запись происходят в SQLite
|
||||||
- MariaDB используется как сервер синхронизации
|
- MariaDB используется как сервер синхронизации
|
||||||
- Background worker: периодический sync push+pull
|
- Background worker: периодический sync push+pull
|
||||||
|
- Система ревизий конфигураций: immutable snapshots при каждом сохранении (local_configuration_versions)
|
||||||
|
|
||||||
## Guardrails
|
## Guardrails
|
||||||
- Не возвращать в проект удалённые legacy-разделы: cron jobs, importer utility, admin pricing, alerts, stock import.
|
- Не возвращать в проект удалённые legacy-разделы: cron jobs, importer utility, admin pricing, alerts, stock import.
|
||||||
- Runtime-конфиг читается из user state (`config.yaml`) или через `-config` / `QFS_CONFIG_PATH`; не хранить рабочий `config.yaml` в репозитории.
|
- Runtime-конфиг читается из user state (`config.yaml`) или через `-config` / `QFS_CONFIG_PATH`; не хранить рабочий `config.yaml` в репозитории.
|
||||||
- `config.example.yaml` остаётся единственным шаблоном конфигурации в репо.
|
- `config.example.yaml` остаётся единственным шаблоном конфигурации в репо.
|
||||||
- Любые изменения в sync должны сохранять local-first поведение: локальные CRUD не блокируются из-за недоступности MariaDB.
|
- Любые изменения в sync должны сохранять local-first поведение: локальные CRUD не блокируются из-за недоступности MariaDB.
|
||||||
|
- CSV-экспорт: имя файла должно содержать **код проекта** (`project.Code`), а не название (`project.Name`). Формат: `YYYY-MM-DD (КодПроекта) ИмяКонфигурации Артикул.csv`.
|
||||||
|
- UI: во всех breadcrumbs длинные названия спецификаций/конфигураций сокращать до 16 символов с многоточием.
|
||||||
|
|
||||||
## Key SQLite Data
|
## Key SQLite Data
|
||||||
- `connection_settings`
|
- `connection_settings`
|
||||||
- `local_components`
|
- `local_components`
|
||||||
- `local_pricelists`, `local_pricelist_items`
|
- `local_pricelists`, `local_pricelist_items`
|
||||||
- `local_configurations`
|
- `local_configurations`
|
||||||
|
- `local_configuration_versions` — immutable snapshots (ревизии) при каждом сохранении
|
||||||
- `local_projects`
|
- `local_projects`
|
||||||
- `pending_changes`
|
- `pending_changes`
|
||||||
|
|
||||||
@@ -42,14 +46,15 @@
|
|||||||
| Components | `GET /api/components`, `GET /api/components/:lot_name`, `GET /api/categories` |
|
| Components | `GET /api/components`, `GET /api/components/:lot_name`, `GET /api/categories` |
|
||||||
| Quote | `POST /api/quote/validate`, `POST /api/quote/calculate`, `POST /api/quote/price-levels` |
|
| Quote | `POST /api/quote/validate`, `POST /api/quote/calculate`, `POST /api/quote/price-levels` |
|
||||||
| Pricelists (read-only) | `GET /api/pricelists`, `GET /api/pricelists/latest`, `GET /api/pricelists/:id`, `GET /api/pricelists/:id/items`, `GET /api/pricelists/:id/lots` |
|
| Pricelists (read-only) | `GET /api/pricelists`, `GET /api/pricelists/latest`, `GET /api/pricelists/:id`, `GET /api/pricelists/:id/items`, `GET /api/pricelists/:id/lots` |
|
||||||
| Configs | CRUD + refresh/clone/reactivate/rename/project binding via `/api/configs/*` |
|
| Configs | CRUD + refresh/clone/reactivate/rename/project binding + versions/rollback via `/api/configs/*` |
|
||||||
| Projects | CRUD + nested configs via `/api/projects/*` |
|
| Projects | CRUD + nested configs + `DELETE /api/projects/:uuid` (delete variant) via `/api/projects/*` |
|
||||||
| Sync | `GET /api/sync/status`, `GET /api/sync/readiness`, `GET /api/sync/info`, `GET /api/sync/users-status`, `POST /api/sync/components`, `POST /api/sync/pricelists`, `POST /api/sync/all`, `POST /api/sync/push`, `GET /api/sync/pending`, `GET /api/sync/pending/count` |
|
| Sync | `GET /api/sync/status`, `GET /api/sync/readiness`, `GET /api/sync/info`, `GET /api/sync/users-status`, `POST /api/sync/components`, `POST /api/sync/pricelists`, `POST /api/sync/all`, `POST /api/sync/push`, `GET /api/sync/pending`, `GET /api/sync/pending/count` |
|
||||||
| Export | `POST /api/export/csv` |
|
| Export | `POST /api/export/csv` |
|
||||||
|
|
||||||
## Web Routes
|
## Web Routes
|
||||||
- `/configs`
|
- `/configs`
|
||||||
- `/configurator`
|
- `/configurator`
|
||||||
|
- `/configs/:uuid/revisions`
|
||||||
- `/projects`
|
- `/projects`
|
||||||
- `/projects/:uuid`
|
- `/projects/:uuid`
|
||||||
- `/pricelists`
|
- `/pricelists`
|
||||||
|
|||||||
@@ -446,6 +446,8 @@ CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/qfs ./cmd/qfs
|
|||||||
| `QFS_DB_PATH` | Полный путь к локальной SQLite БД | OS-specific user state dir |
|
| `QFS_DB_PATH` | Полный путь к локальной SQLite БД | OS-specific user state dir |
|
||||||
| `QFS_STATE_DIR` | Каталог state (если `QFS_DB_PATH` не задан) | OS-specific user state dir |
|
| `QFS_STATE_DIR` | Каталог state (если `QFS_DB_PATH` не задан) | OS-specific user state dir |
|
||||||
| `QFS_CONFIG_PATH` | Полный путь к `config.yaml` | OS-specific user state dir |
|
| `QFS_CONFIG_PATH` | Полный путь к `config.yaml` | OS-specific user state dir |
|
||||||
|
| `QFS_BACKUP_DIR` | Каталог для ротационных бэкапов локальных данных | `<db dir>/backups` |
|
||||||
|
| `QFS_BACKUP_DISABLE` | Отключить автоматические бэкапы (`1/true/yes`) | — |
|
||||||
|
|
||||||
## Интеграция с существующей БД
|
## Интеграция с существующей БД
|
||||||
|
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ func buildPlan(db *gorm.DB, fallbackOwner string) ([]migrationAction, map[string
|
|||||||
}
|
}
|
||||||
for i := range projects {
|
for i := range projects {
|
||||||
p := projects[i]
|
p := projects[i]
|
||||||
existingProjects[projectKey(p.OwnerUsername, p.Name)] = &p
|
existingProjects[projectKey(p.OwnerUsername, derefString(p.Name))] = &p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,12 +253,13 @@ func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[st
|
|||||||
|
|
||||||
for _, action := range actions {
|
for _, action := range actions {
|
||||||
key := projectKey(action.OwnerUsername, action.TargetProjectName)
|
key := projectKey(action.OwnerUsername, action.TargetProjectName)
|
||||||
project := projectCache[key]
|
project := projectCache[key]
|
||||||
if project == nil {
|
if project == nil {
|
||||||
project = &models.Project{
|
project = &models.Project{
|
||||||
UUID: uuid.NewString(),
|
UUID: uuid.NewString(),
|
||||||
OwnerUsername: action.OwnerUsername,
|
OwnerUsername: action.OwnerUsername,
|
||||||
Name: action.TargetProjectName,
|
Code: action.TargetProjectName,
|
||||||
|
Name: ptrString(action.TargetProjectName),
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsSystem: false,
|
IsSystem: false,
|
||||||
}
|
}
|
||||||
@@ -268,7 +269,7 @@ func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[st
|
|||||||
projectCache[key] = project
|
projectCache[key] = project
|
||||||
} else if !project.IsActive {
|
} else if !project.IsActive {
|
||||||
if err := tx.Model(&models.Project{}).Where("uuid = ?", project.UUID).Update("is_active", true).Error; err != nil {
|
if err := tx.Model(&models.Project{}).Where("uuid = ?", project.UUID).Update("is_active", true).Error; err != nil {
|
||||||
return fmt.Errorf("reactivate project %s (%s): %w", project.Name, project.UUID, err)
|
return fmt.Errorf("reactivate project %s (%s): %w", derefString(project.Name), project.UUID, err)
|
||||||
}
|
}
|
||||||
project.IsActive = true
|
project.IsActive = true
|
||||||
}
|
}
|
||||||
@@ -294,3 +295,14 @@ func setKeys(set map[string]struct{}) []string {
|
|||||||
func projectKey(owner, name string) string {
|
func projectKey(owner, name string) string {
|
||||||
return owner + "||" + name
|
return owner + "||" + name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func derefString(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrString(value string) *string {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|||||||
322
cmd/qfs/main.go
322
cmd/qfs/main.go
@@ -50,6 +50,7 @@ const onDemandPullCooldown = 30 * time.Second
|
|||||||
func main() {
|
func main() {
|
||||||
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
|
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
|
||||||
localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
|
localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
|
||||||
|
resetLocalDB := flag.Bool("reset-localdb", false, "reset local SQLite data on startup (keeps connection settings)")
|
||||||
migrate := flag.Bool("migrate", false, "run database migrations")
|
migrate := flag.Bool("migrate", false, "run database migrations")
|
||||||
version := flag.Bool("version", false, "show version information")
|
version := flag.Bool("version", false, "show version information")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -100,6 +101,13 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if shouldResetLocalDB(*resetLocalDB) {
|
||||||
|
if err := localdb.ResetData(resolvedLocalDBPath); err != nil {
|
||||||
|
slog.Error("failed to reset local database", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize local SQLite database (always used)
|
// Initialize local SQLite database (always used)
|
||||||
local, err := localdb.New(resolvedLocalDBPath)
|
local, err := localdb.New(resolvedLocalDBPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -232,6 +240,10 @@ func main() {
|
|||||||
syncWorker := sync.NewWorker(syncService, connMgr, backgroundSyncInterval)
|
syncWorker := sync.NewWorker(syncService, connMgr, backgroundSyncInterval)
|
||||||
go syncWorker.Start(workerCtx)
|
go syncWorker.Start(workerCtx)
|
||||||
|
|
||||||
|
backupCtx, backupCancel := context.WithCancel(context.Background())
|
||||||
|
defer backupCancel()
|
||||||
|
go startBackupScheduler(backupCtx, cfg, resolvedLocalDBPath, resolvedConfigPath)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.Address(),
|
Addr: cfg.Address(),
|
||||||
Handler: router,
|
Handler: router,
|
||||||
@@ -274,6 +286,7 @@ func main() {
|
|||||||
// Stop background sync worker first
|
// Stop background sync worker first
|
||||||
syncWorker.Stop()
|
syncWorker.Stop()
|
||||||
workerCancel()
|
workerCancel()
|
||||||
|
backupCancel()
|
||||||
|
|
||||||
// Then shutdown HTTP server
|
// Then shutdown HTTP server
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
@@ -290,6 +303,31 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldResetLocalDB(flagValue bool) bool {
|
||||||
|
if flagValue {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
value := strings.TrimSpace(os.Getenv("QFS_RESET_LOCAL_DB"))
|
||||||
|
if value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(value) {
|
||||||
|
case "1", "true", "yes", "y":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func derefString(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func setConfigDefaults(cfg *config.Config) {
|
func setConfigDefaults(cfg *config.Config) {
|
||||||
if cfg.Server.Host == "" {
|
if cfg.Server.Host == "" {
|
||||||
cfg.Server.Host = "127.0.0.1"
|
cfg.Server.Host = "127.0.0.1"
|
||||||
@@ -324,6 +362,9 @@ func setConfigDefaults(cfg *config.Config) {
|
|||||||
if cfg.Pricing.MinQuotesForMedian == 0 {
|
if cfg.Pricing.MinQuotesForMedian == 0 {
|
||||||
cfg.Pricing.MinQuotesForMedian = 3
|
cfg.Pricing.MinQuotesForMedian = 3
|
||||||
}
|
}
|
||||||
|
if cfg.Backup.Time == "" {
|
||||||
|
cfg.Backup.Time = "00:00"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureDefaultConfigFile(configPath string) error {
|
func ensureDefaultConfigFile(configPath string) error {
|
||||||
@@ -347,6 +388,9 @@ func ensureDefaultConfigFile(configPath string) error {
|
|||||||
read_timeout: 30s
|
read_timeout: 30s
|
||||||
write_timeout: 30s
|
write_timeout: 30s
|
||||||
|
|
||||||
|
backup:
|
||||||
|
time: "00:00"
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level: "info"
|
level: "info"
|
||||||
format: "json"
|
format: "json"
|
||||||
@@ -373,9 +417,14 @@ type runtimeLoggingConfig struct {
|
|||||||
Output string `yaml:"output"`
|
Output string `yaml:"output"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type runtimeBackupConfig struct {
|
||||||
|
Time string `yaml:"time"`
|
||||||
|
}
|
||||||
|
|
||||||
type runtimeConfigFile struct {
|
type runtimeConfigFile struct {
|
||||||
Server runtimeServerConfig `yaml:"server"`
|
Server runtimeServerConfig `yaml:"server"`
|
||||||
Logging runtimeLoggingConfig `yaml:"logging"`
|
Logging runtimeLoggingConfig `yaml:"logging"`
|
||||||
|
Backup runtimeBackupConfig `yaml:"backup"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrateConfigFileToRuntimeShape rewrites config.yaml in a minimal runtime format.
|
// migrateConfigFileToRuntimeShape rewrites config.yaml in a minimal runtime format.
|
||||||
@@ -398,6 +447,9 @@ func migrateConfigFileToRuntimeShape(configPath string, cfg *config.Config) erro
|
|||||||
Format: cfg.Logging.Format,
|
Format: cfg.Logging.Format,
|
||||||
Output: cfg.Logging.Output,
|
Output: cfg.Logging.Output,
|
||||||
},
|
},
|
||||||
|
Backup: runtimeBackupConfig{
|
||||||
|
Time: cfg.Backup.Time,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
rendered, err := yaml.Marshal(&runtimeCfg)
|
rendered, err := yaml.Marshal(&runtimeCfg)
|
||||||
@@ -416,6 +468,69 @@ func migrateConfigFileToRuntimeShape(configPath string, cfg *config.Config) erro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
|
||||||
|
if cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hour, minute, err := parseBackupTime(cfg.Backup.Time)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err)
|
||||||
|
hour = 0
|
||||||
|
minute = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil {
|
||||||
|
slog.Error("local backup failed", "error", backupErr)
|
||||||
|
} else if len(created) > 0 {
|
||||||
|
for _, path := range created {
|
||||||
|
slog.Info("local backup completed", "archive", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
next := nextBackupTime(time.Now(), hour, minute)
|
||||||
|
timer := time.NewTimer(time.Until(next))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
start := time.Now()
|
||||||
|
created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath)
|
||||||
|
duration := time.Since(start)
|
||||||
|
if backupErr != nil {
|
||||||
|
slog.Error("local backup failed", "error", backupErr, "duration", duration)
|
||||||
|
} else {
|
||||||
|
for _, path := range created {
|
||||||
|
slog.Info("local backup completed", "archive", path, "duration", duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBackupTime(value string) (int, int, error) {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return 0, 0, fmt.Errorf("empty backup time")
|
||||||
|
}
|
||||||
|
parsed, err := time.Parse("15:04", value)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return parsed.Hour(), parsed.Minute(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextBackupTime(now time.Time, hour, minute int) time.Time {
|
||||||
|
location := now.Location()
|
||||||
|
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, location)
|
||||||
|
if !now.Before(target) {
|
||||||
|
target = target.Add(24 * time.Hour)
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
// runSetupMode starts a minimal server that only serves the setup page
|
// runSetupMode starts a minimal server that only serves the setup page
|
||||||
func runSetupMode(local *localdb.LocalDB) {
|
func runSetupMode(local *localdb.LocalDB) {
|
||||||
restartSig := make(chan struct{}, 1)
|
restartSig := make(chan struct{}, 1)
|
||||||
@@ -581,12 +696,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
if mariaDB != nil {
|
if mariaDB != nil {
|
||||||
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
||||||
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, nil)
|
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, nil)
|
||||||
exportService = services.NewExportService(cfg.Export, categoryRepo)
|
exportService = services.NewExportService(cfg.Export, categoryRepo, local)
|
||||||
} else {
|
} else {
|
||||||
// In offline mode, we still need to create services that don't require DB.
|
// In offline mode, we still need to create services that don't require DB.
|
||||||
componentService = services.NewComponentService(nil, nil, nil)
|
componentService = services.NewComponentService(nil, nil, nil)
|
||||||
quoteService = services.NewQuoteService(nil, nil, nil, local, nil)
|
quoteService = services.NewQuoteService(nil, nil, nil, local, nil)
|
||||||
exportService = services.NewExportService(cfg.Export, nil)
|
exportService = services.NewExportService(cfg.Export, nil, local)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isOnline function for local-first architecture
|
// isOnline function for local-first architecture
|
||||||
@@ -695,7 +810,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
// Handlers
|
// Handlers
|
||||||
componentHandler := handlers.NewComponentHandler(componentService, local)
|
componentHandler := handlers.NewComponentHandler(componentService, local)
|
||||||
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
||||||
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
exportHandler := handlers.NewExportHandler(exportService, configService, projectService)
|
||||||
pricelistHandler := handlers.NewPricelistHandler(local)
|
pricelistHandler := handlers.NewPricelistHandler(local)
|
||||||
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
|
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -815,6 +930,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
router.GET("/configurator", webHandler.Configurator)
|
router.GET("/configurator", webHandler.Configurator)
|
||||||
router.GET("/projects", webHandler.Projects)
|
router.GET("/projects", webHandler.Projects)
|
||||||
router.GET("/projects/:uuid", webHandler.ProjectDetail)
|
router.GET("/projects/:uuid", webHandler.ProjectDetail)
|
||||||
|
router.GET("/configs/:uuid/revisions", webHandler.ConfigRevisions)
|
||||||
router.GET("/pricelists", webHandler.Pricelists)
|
router.GET("/pricelists", webHandler.Pricelists)
|
||||||
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
||||||
|
|
||||||
@@ -926,6 +1042,23 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
c.JSON(http.StatusCreated, config)
|
c.JSON(http.StatusCreated, config)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
configs.POST("/preview-article", func(c *gin.Context) {
|
||||||
|
var req services.ArticlePreviewRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := configService.BuildArticlePreview(&req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"article": result.Article,
|
||||||
|
"warnings": result.Warnings,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
configs.GET("/:uuid", func(c *gin.Context) {
|
configs.GET("/:uuid", func(c *gin.Context) {
|
||||||
uuid := c.Param("uuid")
|
uuid := c.Param("uuid")
|
||||||
config, err := configService.GetByUUIDNoAuth(uuid)
|
config, err := configService.GetByUUIDNoAuth(uuid)
|
||||||
@@ -946,7 +1079,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
|
|
||||||
config, err := configService.UpdateNoAuth(uuid, &req)
|
config, err := configService.UpdateNoAuth(uuid, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
switch {
|
||||||
|
case errors.Is(err, services.ErrConfigNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -997,15 +1139,20 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
configs.POST("/:uuid/clone", func(c *gin.Context) {
|
configs.POST("/:uuid/clone", func(c *gin.Context) {
|
||||||
uuid := c.Param("uuid")
|
uuid := c.Param("uuid")
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
FromVersion int `json:"from_version"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := configService.CloneNoAuth(uuid, req.Name, dbUsername)
|
config, err := configService.CloneNoAuthToProjectFromVersion(uuid, req.Name, dbUsername, nil, req.FromVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, services.ErrConfigVersionNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1154,6 +1301,23 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
})
|
})
|
||||||
|
|
||||||
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
|
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
|
||||||
|
|
||||||
|
configs.PATCH("/:uuid/server-count", func(c *gin.Context) {
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
var req struct {
|
||||||
|
ServerCount int `json:"server_count" binding:"required,min=1"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config, err := configService.UpdateServerCount(uuid, req.ServerCount)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, config)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
projects := api.Group("/projects")
|
projects := api.Group("/projects")
|
||||||
@@ -1207,7 +1371,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
if status == "archived" && p.IsActive {
|
if status == "archived" && p.IsActive {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if search != "" && !strings.Contains(strings.ToLower(p.Name), search) {
|
if search != "" &&
|
||||||
|
!strings.Contains(strings.ToLower(derefString(p.Name)), search) &&
|
||||||
|
!strings.Contains(strings.ToLower(p.Code), search) &&
|
||||||
|
!strings.Contains(strings.ToLower(p.Variant), search) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if author != "" && !strings.Contains(strings.ToLower(strings.TrimSpace(p.OwnerUsername)), author) {
|
if author != "" && !strings.Contains(strings.ToLower(strings.TrimSpace(p.OwnerUsername)), author) {
|
||||||
@@ -1220,8 +1387,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
left := filtered[i]
|
left := filtered[i]
|
||||||
right := filtered[j]
|
right := filtered[j]
|
||||||
if sortField == "name" {
|
if sortField == "name" {
|
||||||
leftName := strings.ToLower(strings.TrimSpace(left.Name))
|
leftName := strings.ToLower(strings.TrimSpace(derefString(left.Name)))
|
||||||
rightName := strings.ToLower(strings.TrimSpace(right.Name))
|
rightName := strings.ToLower(strings.TrimSpace(derefString(right.Name)))
|
||||||
if leftName == rightName {
|
if leftName == rightName {
|
||||||
if sortDir == "asc" {
|
if sortDir == "asc" {
|
||||||
return left.CreatedAt.Before(right.CreatedAt)
|
return left.CreatedAt.Before(right.CreatedAt)
|
||||||
@@ -1234,8 +1401,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
return leftName > rightName
|
return leftName > rightName
|
||||||
}
|
}
|
||||||
if left.CreatedAt.Equal(right.CreatedAt) {
|
if left.CreatedAt.Equal(right.CreatedAt) {
|
||||||
leftName := strings.ToLower(strings.TrimSpace(left.Name))
|
leftName := strings.ToLower(strings.TrimSpace(derefString(left.Name)))
|
||||||
rightName := strings.ToLower(strings.TrimSpace(right.Name))
|
rightName := strings.ToLower(strings.TrimSpace(derefString(right.Name)))
|
||||||
if sortDir == "asc" {
|
if sortDir == "asc" {
|
||||||
return leftName < rightName
|
return leftName < rightName
|
||||||
}
|
}
|
||||||
@@ -1294,6 +1461,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
"id": p.ID,
|
"id": p.ID,
|
||||||
"uuid": p.UUID,
|
"uuid": p.UUID,
|
||||||
"owner_username": p.OwnerUsername,
|
"owner_username": p.OwnerUsername,
|
||||||
|
"code": p.Code,
|
||||||
|
"variant": p.Variant,
|
||||||
"name": p.Name,
|
"name": p.Name,
|
||||||
"tracker_url": p.TrackerURL,
|
"tracker_url": p.TrackerURL,
|
||||||
"is_active": p.IsActive,
|
"is_active": p.IsActive,
|
||||||
@@ -1319,31 +1488,36 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// GET /api/projects/all - Returns all projects without pagination for UI dropdowns
|
||||||
|
projects.GET("/all", func(c *gin.Context) {
|
||||||
|
allProjects, err := projectService.ListByUser(dbUsername, true)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/projects/all - Returns all projects without pagination for UI dropdowns
|
// Return simplified list of all projects (UUID + Name only)
|
||||||
projects.GET("/all", func(c *gin.Context) {
|
type ProjectSimple struct {
|
||||||
allProjects, err := projectService.ListByUser(dbUsername, true)
|
UUID string `json:"uuid"`
|
||||||
if err != nil {
|
Code string `json:"code"`
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
Variant string `json:"variant"`
|
||||||
return
|
Name string `json:"name"`
|
||||||
}
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
// Return simplified list of all projects (UUID + Name only)
|
simplified := make([]ProjectSimple, 0, len(allProjects))
|
||||||
type ProjectSimple struct {
|
for _, p := range allProjects {
|
||||||
UUID string `json:"uuid"`
|
simplified = append(simplified, ProjectSimple{
|
||||||
Name string `json:"name"`
|
UUID: p.UUID,
|
||||||
}
|
Code: p.Code,
|
||||||
|
Variant: p.Variant,
|
||||||
|
Name: derefString(p.Name),
|
||||||
|
IsActive: p.IsActive,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
simplified := make([]ProjectSimple, 0, len(allProjects))
|
c.JSON(http.StatusOK, simplified)
|
||||||
for _, p := range allProjects {
|
})
|
||||||
simplified = append(simplified, ProjectSimple{
|
|
||||||
UUID: p.UUID,
|
|
||||||
Name: p.Name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, simplified)
|
|
||||||
})
|
|
||||||
|
|
||||||
projects.POST("", func(c *gin.Context) {
|
projects.POST("", func(c *gin.Context) {
|
||||||
var req services.CreateProjectRequest
|
var req services.CreateProjectRequest
|
||||||
@@ -1351,13 +1525,18 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.Name) == "" {
|
if strings.TrimSpace(req.Code) == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "project code is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
project, err := projectService.Create(dbUsername, &req)
|
project, err := projectService.Create(dbUsername, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
switch {
|
||||||
|
case errors.Is(err, services.ErrProjectCodeExists):
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusCreated, project)
|
c.JSON(http.StatusCreated, project)
|
||||||
@@ -1385,13 +1564,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.Name) == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
|
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
|
case errors.Is(err, services.ErrProjectCodeExists):
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
case errors.Is(err, services.ErrProjectNotFound):
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
case errors.Is(err, services.ErrProjectForbidden):
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
@@ -1434,6 +1611,23 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "project reactivated"})
|
c.JSON(http.StatusOK, gin.H{"message": "project reactivated"})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
projects.DELETE("/:uuid", func(c *gin.Context) {
|
||||||
|
if err := projectService.DeleteVariant(c.Param("uuid"), dbUsername); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, services.ErrCannotDeleteMainVariant):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "variant deleted"})
|
||||||
|
})
|
||||||
|
|
||||||
projects.GET("/:uuid/configs", func(c *gin.Context) {
|
projects.GET("/:uuid/configs", func(c *gin.Context) {
|
||||||
triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
|
triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
|
||||||
|
|
||||||
@@ -1476,6 +1670,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
c.JSON(http.StatusCreated, config)
|
c.JSON(http.StatusCreated, config)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
projects.GET("/:uuid/export", exportHandler.ExportProjectCSV)
|
||||||
|
|
||||||
projects.POST("/:uuid/configs/:config_uuid/clone", func(c *gin.Context) {
|
projects.POST("/:uuid/configs/:config_uuid/clone", func(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -1508,6 +1704,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
syncAPI.POST("/push", syncHandler.PushPendingChanges)
|
syncAPI.POST("/push", syncHandler.PushPendingChanges)
|
||||||
syncAPI.GET("/pending/count", syncHandler.GetPendingCount)
|
syncAPI.GET("/pending/count", syncHandler.GetPendingCount)
|
||||||
syncAPI.GET("/pending", syncHandler.GetPendingChanges)
|
syncAPI.GET("/pending", syncHandler.GetPendingChanges)
|
||||||
|
syncAPI.POST("/repair", syncHandler.RepairPendingChanges)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1559,11 +1756,37 @@ func requestLogger() gin.HandlerFunc {
|
|||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
query := c.Request.URL.RawQuery
|
query := c.Request.URL.RawQuery
|
||||||
|
|
||||||
|
blw := &captureResponseWriter{
|
||||||
|
ResponseWriter: c.Writer,
|
||||||
|
body: bytes.NewBuffer(nil),
|
||||||
|
}
|
||||||
|
c.Writer = blw
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
status := c.Writer.Status()
|
status := c.Writer.Status()
|
||||||
|
|
||||||
|
if status >= http.StatusBadRequest {
|
||||||
|
responseBody := strings.TrimSpace(blw.body.String())
|
||||||
|
if len(responseBody) > 2048 {
|
||||||
|
responseBody = responseBody[:2048] + "...(truncated)"
|
||||||
|
}
|
||||||
|
errText := strings.TrimSpace(c.Errors.String())
|
||||||
|
|
||||||
|
slog.Error("request failed",
|
||||||
|
"method", c.Request.Method,
|
||||||
|
"path", path,
|
||||||
|
"query", query,
|
||||||
|
"status", status,
|
||||||
|
"latency", latency,
|
||||||
|
"ip", c.ClientIP(),
|
||||||
|
"errors", errText,
|
||||||
|
"response", responseBody,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
slog.Info("request",
|
slog.Info("request",
|
||||||
"method", c.Request.Method,
|
"method", c.Request.Method,
|
||||||
"path", path,
|
"path", path,
|
||||||
@@ -1574,3 +1797,22 @@ func requestLogger() gin.HandlerFunc {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type captureResponseWriter struct {
|
||||||
|
gin.ResponseWriter
|
||||||
|
body *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *captureResponseWriter) Write(b []byte) (int, error) {
|
||||||
|
if len(b) > 0 {
|
||||||
|
_, _ = w.body.Write(b)
|
||||||
|
}
|
||||||
|
return w.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *captureResponseWriter) WriteString(s string) (int, error) {
|
||||||
|
if s != "" {
|
||||||
|
_, _ = w.body.WriteString(s)
|
||||||
|
}
|
||||||
|
return w.ResponseWriter.WriteString(s)
|
||||||
|
}
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ func TestProjectArchiveHidesConfigsAndCloneIntoProject(t *testing.T) {
|
|||||||
t.Fatalf("setup router: %v", err)
|
t.Fatalf("setup router: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1"}`)))
|
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1","code":"P1"}`)))
|
||||||
createProjectReq.Header.Set("Content-Type", "application/json")
|
createProjectReq.Header.Set("Content-Type", "application/json")
|
||||||
createProjectRec := httptest.NewRecorder()
|
createProjectRec := httptest.NewRecorder()
|
||||||
router.ServeHTTP(createProjectRec, createProjectReq)
|
router.ServeHTTP(createProjectRec, createProjectReq)
|
||||||
@@ -243,7 +243,7 @@ func TestConfigMoveToProjectEndpoint(t *testing.T) {
|
|||||||
t.Fatalf("setup router: %v", err)
|
t.Fatalf("setup router: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project"}`)))
|
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project","code":"MOVE"}`)))
|
||||||
createProjectReq.Header.Set("Content-Type", "application/json")
|
createProjectReq.Header.Set("Content-Type", "application/json")
|
||||||
createProjectRec := httptest.NewRecorder()
|
createProjectRec := httptest.NewRecorder()
|
||||||
router.ServeHTTP(createProjectRec, createProjectReq)
|
router.ServeHTTP(createProjectRec, createProjectReq)
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export:
|
|||||||
max_file_age: "1h"
|
max_file_age: "1h"
|
||||||
company_name: "Your Company Name"
|
company_name: "Your Company Name"
|
||||||
|
|
||||||
|
backup:
|
||||||
|
time: "00:00"
|
||||||
|
|
||||||
alerts:
|
alerts:
|
||||||
enabled: true
|
enabled: true
|
||||||
check_interval: "1h"
|
check_interval: "1h"
|
||||||
|
|||||||
297
csv_export.md
297
csv_export.md
@@ -1,297 +0,0 @@
|
|||||||
# CSV Export Pattern (Go + GORM)
|
|
||||||
|
|
||||||
## Архитектура (3-слойная)
|
|
||||||
|
|
||||||
### 1. Handler Layer (HTTP)
|
|
||||||
**Задачи**: Обработка HTTP-запроса, установка заголовков, инициация экспорта
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (h *PricelistHandler) ExportCSV(c *gin.Context) {
|
|
||||||
// 1. Валидация параметров
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
||||||
|
|
||||||
// 2. Получение метаданных для формирования имени файла
|
|
||||||
pl, err := h.service.GetByID(uint(id))
|
|
||||||
|
|
||||||
// 3. Установка HTTP-заголовков для скачивания
|
|
||||||
filename := fmt.Sprintf("pricelist_%s.csv", pl.Version)
|
|
||||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
|
||||||
|
|
||||||
// 4. UTF-8 BOM для Excel-совместимости
|
|
||||||
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
|
|
||||||
|
|
||||||
// 5. Настройка CSV writer
|
|
||||||
writer := csv.NewWriter(c.Writer)
|
|
||||||
writer.Comma = ';' // Точка с запятой для Excel
|
|
||||||
defer writer.Flush()
|
|
||||||
|
|
||||||
// 6. Динамические заголовки (зависят от типа данных)
|
|
||||||
isWarehouse := strings.ToLower(pl.Source) == "warehouse"
|
|
||||||
var header []string
|
|
||||||
if isWarehouse {
|
|
||||||
header = []string{"Артикул", "Категория", "Описание", "Доступно", "Partnumbers", "Цена, $", "Настройки"}
|
|
||||||
} else {
|
|
||||||
header = []string{"Артикул", "Категория", "Описание", "Цена, $", "Настройки"}
|
|
||||||
}
|
|
||||||
writer.Write(header)
|
|
||||||
|
|
||||||
// 7. Streaming в batches через callback
|
|
||||||
err = h.service.StreamItemsForExport(uint(id), 500, func(items []models.PricelistItem) error {
|
|
||||||
for _, item := range items {
|
|
||||||
row := buildRow(item, isWarehouse)
|
|
||||||
if err := writer.Write(row); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writer.Flush() // Flush после каждого batch
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Service Layer
|
|
||||||
**Задачи**: Оркестрация, делегирование в репозиторий
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (s *Service) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error {
|
|
||||||
if s.repo == nil {
|
|
||||||
return fmt.Errorf("offline mode: cannot stream pricelist items")
|
|
||||||
}
|
|
||||||
return s.repo.StreamItemsForExport(pricelistID, batchSize, callback)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Repository Layer (Критичный)
|
|
||||||
**Задачи**: Batch-загрузка из БД, оптимизация запросов, enrichment
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (r *PricelistRepository) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error {
|
|
||||||
if batchSize <= 0 {
|
|
||||||
batchSize = 500 // Default batch size
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка типа pricelist для conditional enrichment
|
|
||||||
var pl models.Pricelist
|
|
||||||
isWarehouse := false
|
|
||||||
if err := r.db.Select("source").Where("id = ?", pricelistID).First(&pl).Error; err == nil {
|
|
||||||
isWarehouse = pl.Source == string(models.PricelistSourceWarehouse)
|
|
||||||
}
|
|
||||||
|
|
||||||
offset := 0
|
|
||||||
for {
|
|
||||||
var items []models.PricelistItem
|
|
||||||
|
|
||||||
// ⚡ КЛЮЧЕВОЙ МОМЕНТ: JOIN для избежания N+1 запросов
|
|
||||||
err := r.db.Table("qt_pricelist_items AS pi").
|
|
||||||
Select("pi.*, COALESCE(l.lot_description, '') AS lot_description, COALESCE(l.lot_category, '') AS category").
|
|
||||||
Joins("LEFT JOIN lot AS l ON l.lot_name = pi.lot_name").
|
|
||||||
Where("pi.pricelist_id = ?", pricelistID).
|
|
||||||
Order("pi.lot_name").
|
|
||||||
Offset(offset).
|
|
||||||
Limit(batchSize).
|
|
||||||
Scan(&items).Error
|
|
||||||
|
|
||||||
if err != nil || len(items) == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Conditional enrichment для warehouse данных
|
|
||||||
if isWarehouse {
|
|
||||||
r.enrichWarehouseItems(items) // Добавление qty, partnumbers
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вызов callback для обработки batch
|
|
||||||
if err := callback(items); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(items) < batchSize {
|
|
||||||
break // Последний batch
|
|
||||||
}
|
|
||||||
|
|
||||||
offset += batchSize
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ключевые паттерны
|
|
||||||
|
|
||||||
### 1. Streaming (не загружать все в память)
|
|
||||||
```go
|
|
||||||
// ❌ НЕ ТАК:
|
|
||||||
var allItems []Item
|
|
||||||
db.Find(&allItems) // Может упасть на миллионах записей
|
|
||||||
|
|
||||||
// ✅ ТАК:
|
|
||||||
for offset := 0; ; offset += batchSize {
|
|
||||||
var batch []Item
|
|
||||||
db.Offset(offset).Limit(batchSize).Find(&batch)
|
|
||||||
processBatch(batch)
|
|
||||||
if len(batch) < batchSize {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Callback Pattern для гибкости
|
|
||||||
```go
|
|
||||||
// Service не знает о CSV - может использоваться для любого экспорта
|
|
||||||
func StreamItems(callback func([]Item) error) error
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. JOIN для избежания N+1
|
|
||||||
```go
|
|
||||||
// ❌ N+1 problem:
|
|
||||||
items := getItems()
|
|
||||||
for _, item := range items {
|
|
||||||
description := getLotDescription(item.LotName) // N запросов
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ JOIN:
|
|
||||||
db.Table("items AS i").
|
|
||||||
Select("i.*, COALESCE(l.description, '') AS description").
|
|
||||||
Joins("LEFT JOIN lots AS l ON l.name = i.lot_name")
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. UTF-8 BOM для Excel
|
|
||||||
```go
|
|
||||||
// Excel на Windows требует BOM для корректного отображения UTF-8
|
|
||||||
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Точка с запятой для Excel
|
|
||||||
```go
|
|
||||||
writer := csv.NewWriter(c.Writer)
|
|
||||||
writer.Comma = ';' // Excel в русской локали использует ;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Graceful Error Handling
|
|
||||||
```go
|
|
||||||
// После начала streaming нельзя вернуть JSON
|
|
||||||
if err != nil {
|
|
||||||
// Уже начали писать CSV, поэтому пишем текст
|
|
||||||
c.String(http.StatusInternalServerError, "Export failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Conditional Enrichment Pattern
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Для warehouse прайслистов добавляем дополнительные поля
|
|
||||||
func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem) error {
|
|
||||||
// 1. Собрать уникальные lot_names
|
|
||||||
lots := make([]string, 0, len(items))
|
|
||||||
seen := make(map[string]struct{})
|
|
||||||
for _, item := range items {
|
|
||||||
if _, ok := seen[item.LotName]; !ok {
|
|
||||||
lots = append(lots, item.LotName)
|
|
||||||
seen[item.LotName] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Batch-загрузка метрик (qty, partnumbers)
|
|
||||||
qtyByLot, partnumbersByLot, err := warehouse.LoadLotMetrics(r.db, lots, true)
|
|
||||||
|
|
||||||
// 3. Обогащение items
|
|
||||||
for i := range items {
|
|
||||||
if qty, ok := qtyByLot[items[i].LotName]; ok {
|
|
||||||
items[i].AvailableQty = &qty
|
|
||||||
}
|
|
||||||
items[i].Partnumbers = partnumbersByLot[items[i].LotName]
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Virtual Fields Pattern
|
|
||||||
|
|
||||||
```go
|
|
||||||
type PricelistItem struct {
|
|
||||||
// Stored fields
|
|
||||||
ID uint `gorm:"primaryKey"`
|
|
||||||
LotName string `gorm:"size:255"`
|
|
||||||
Price float64 `gorm:"type:decimal(12,2)"`
|
|
||||||
|
|
||||||
// Virtual fields (populated via JOIN or programmatically)
|
|
||||||
LotDescription string `gorm:"-:migration" json:"lot_description,omitempty"`
|
|
||||||
Category string `gorm:"-:migration" json:"category,omitempty"`
|
|
||||||
AvailableQty *float64 `gorm:"-" json:"available_qty,omitempty"`
|
|
||||||
Partnumbers []string `gorm:"-" json:"partnumbers,omitempty"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `gorm:"-:migration"` - не создавать колонку в БД, но маппить при SELECT
|
|
||||||
- `gorm:"-"` - полностью игнорировать при БД операциях
|
|
||||||
|
|
||||||
## Checklist для CSV Export
|
|
||||||
|
|
||||||
- [ ] HTTP заголовки: Content-Type, Content-Disposition
|
|
||||||
- [ ] UTF-8 BOM для Excel (0xEF, 0xBB, 0xBF)
|
|
||||||
- [ ] Разделитель (`;` для русской локали Excel)
|
|
||||||
- [ ] Streaming с batch processing (не загружать всё в память)
|
|
||||||
- [ ] JOIN для избежания N+1 запросов
|
|
||||||
- [ ] Flush после каждого batch
|
|
||||||
- [ ] Graceful error handling (нельзя JSON после начала streaming)
|
|
||||||
- [ ] Динамические заголовки (если нужно)
|
|
||||||
- [ ] Conditional enrichment (если данные зависят от типа)
|
|
||||||
|
|
||||||
## Когда использовать этот паттерн
|
|
||||||
|
|
||||||
✅ **Используй когда:**
|
|
||||||
- Экспорт больших датасетов (>1000 записей)
|
|
||||||
- Нужна Excel-совместимость
|
|
||||||
- Связанные данные из нескольких таблиц
|
|
||||||
- Conditional логика enrichment
|
|
||||||
|
|
||||||
❌ **Не нужен когда:**
|
|
||||||
- Малые датасеты (<100 записей) - можно загрузить всё сразу
|
|
||||||
- Экспорт JSON/XML - другие подходы
|
|
||||||
- Нет связанных данных - можно упростить
|
|
||||||
|
|
||||||
## Пример роутинга (Gin)
|
|
||||||
|
|
||||||
```go
|
|
||||||
// В файле роутера
|
|
||||||
func SetupRoutes(router *gin.Engine, handler *PricelistHandler) {
|
|
||||||
api := router.Group("/api")
|
|
||||||
{
|
|
||||||
pricelists := api.Group("/pricelists")
|
|
||||||
{
|
|
||||||
pricelists.GET("/:id/export", handler.ExportCSV)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Импорты
|
|
||||||
|
|
||||||
```go
|
|
||||||
import (
|
|
||||||
"encoding/csv"
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Notes
|
|
||||||
|
|
||||||
1. **Batch Size**: 500-1000 оптимально для большинства случаев
|
|
||||||
2. **JOIN vs N+1**: JOIN на порядки быстрее при >100 записях
|
|
||||||
3. **Memory**: Streaming позволяет экспортировать миллионы записей с минимальной памятью
|
|
||||||
4. **Indexes**: Убедись что есть индексы на JOIN колонках
|
|
||||||
|
|
||||||
## Источник
|
|
||||||
|
|
||||||
Реализовано в проекте PriceForge:
|
|
||||||
- Handler: `internal/handlers/pricelist.go:245-346`
|
|
||||||
- Service: `internal/services/pricelist/service.go:373-379`
|
|
||||||
- Repository: `internal/repository/pricelist.go:475-533`
|
|
||||||
- Models: `internal/models/pricelist.go`
|
|
||||||
273
internal/appstate/backup.go
Normal file
273
internal/appstate/backup.go
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
package appstate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type backupPeriod struct {
|
||||||
|
name string
|
||||||
|
retention int
|
||||||
|
key func(time.Time) string
|
||||||
|
date func(time.Time) string
|
||||||
|
}
|
||||||
|
|
||||||
|
var backupPeriods = []backupPeriod{
|
||||||
|
{
|
||||||
|
name: "daily",
|
||||||
|
retention: 7,
|
||||||
|
key: func(t time.Time) string {
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
},
|
||||||
|
date: func(t time.Time) string {
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "weekly",
|
||||||
|
retention: 4,
|
||||||
|
key: func(t time.Time) string {
|
||||||
|
y, w := t.ISOWeek()
|
||||||
|
return fmt.Sprintf("%04d-W%02d", y, w)
|
||||||
|
},
|
||||||
|
date: func(t time.Time) string {
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "monthly",
|
||||||
|
retention: 12,
|
||||||
|
key: func(t time.Time) string {
|
||||||
|
return t.Format("2006-01")
|
||||||
|
},
|
||||||
|
date: func(t time.Time) string {
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "yearly",
|
||||||
|
retention: 10,
|
||||||
|
key: func(t time.Time) string {
|
||||||
|
return t.Format("2006")
|
||||||
|
},
|
||||||
|
date: func(t time.Time) string {
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
envBackupDisable = "QFS_BACKUP_DISABLE"
|
||||||
|
envBackupDir = "QFS_BACKUP_DIR"
|
||||||
|
)
|
||||||
|
|
||||||
|
var backupNow = time.Now
|
||||||
|
|
||||||
|
// EnsureRotatingLocalBackup creates or refreshes daily/weekly/monthly/yearly backups
|
||||||
|
// for the local database and config. It keeps a limited number per period.
|
||||||
|
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
|
||||||
|
if isBackupDisabled() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if dbPath == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(dbPath); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("stat db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
root := resolveBackupRoot(dbPath)
|
||||||
|
now := backupNow()
|
||||||
|
|
||||||
|
created := make([]string, 0)
|
||||||
|
for _, period := range backupPeriods {
|
||||||
|
newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath)
|
||||||
|
if err != nil {
|
||||||
|
return created, err
|
||||||
|
}
|
||||||
|
if len(newFiles) > 0 {
|
||||||
|
created = append(created, newFiles...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveBackupRoot(dbPath string) string {
|
||||||
|
if fromEnv := strings.TrimSpace(os.Getenv(envBackupDir)); fromEnv != "" {
|
||||||
|
return filepath.Clean(fromEnv)
|
||||||
|
}
|
||||||
|
return filepath.Join(filepath.Dir(dbPath), "backups")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBackupDisabled() bool {
|
||||||
|
val := strings.ToLower(strings.TrimSpace(os.Getenv(envBackupDisable)))
|
||||||
|
return val == "1" || val == "true" || val == "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensurePeriodBackup(root string, period backupPeriod, now time.Time, dbPath, configPath string) ([]string, error) {
|
||||||
|
key := period.key(now)
|
||||||
|
periodDir := filepath.Join(root, period.name)
|
||||||
|
if err := os.MkdirAll(periodDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create %s backup dir: %w", period.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasBackupForKey(periodDir, key) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveName := fmt.Sprintf("qfs-backp-%s.zip", period.date(now))
|
||||||
|
archivePath := filepath.Join(periodDir, archiveName)
|
||||||
|
|
||||||
|
if err := createBackupArchive(archivePath, dbPath, configPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("create %s backup archive: %w", period.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writePeriodMarker(periodDir, key); err != nil {
|
||||||
|
return []string{archivePath}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pruneOldBackups(periodDir, period.retention); err != nil {
|
||||||
|
return []string{archivePath}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return []string{archivePath}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasBackupForKey(periodDir, key string) bool {
|
||||||
|
marker := periodMarker{Key: ""}
|
||||||
|
data, err := os.ReadFile(periodMarkerPath(periodDir))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &marker); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return marker.Key == key
|
||||||
|
}
|
||||||
|
|
||||||
|
type periodMarker struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func periodMarkerPath(periodDir string) string {
|
||||||
|
return filepath.Join(periodDir, ".period.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePeriodMarker(periodDir, key string) error {
|
||||||
|
data, err := json.MarshalIndent(periodMarker{Key: key}, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(periodMarkerPath(periodDir), data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pruneOldBackups(periodDir string, keep int) error {
|
||||||
|
entries, err := os.ReadDir(periodDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read backups dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files := make([]os.DirEntry, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(entry.Name(), ".zip") {
|
||||||
|
files = append(files, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) <= keep {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(files, func(i, j int) bool {
|
||||||
|
infoI, errI := files[i].Info()
|
||||||
|
infoJ, errJ := files[j].Info()
|
||||||
|
if errI != nil || errJ != nil {
|
||||||
|
return files[i].Name() < files[j].Name()
|
||||||
|
}
|
||||||
|
return infoI.ModTime().Before(infoJ.ModTime())
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 0; i < len(files)-keep; i++ {
|
||||||
|
path := filepath.Join(periodDir, files[i].Name())
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
return fmt.Errorf("remove old backup %s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createBackupArchive(destPath, dbPath, configPath string) error {
|
||||||
|
file, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
zipWriter := zip.NewWriter(file)
|
||||||
|
if err := addZipFile(zipWriter, dbPath); err != nil {
|
||||||
|
_ = zipWriter.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = addZipOptionalFile(zipWriter, dbPath+"-wal")
|
||||||
|
_ = addZipOptionalFile(zipWriter, dbPath+"-shm")
|
||||||
|
|
||||||
|
if strings.TrimSpace(configPath) != "" {
|
||||||
|
_ = addZipOptionalFile(zipWriter, configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := zipWriter.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return file.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
func addZipOptionalFile(writer *zip.Writer, path string) error {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return addZipFile(writer, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addZipFile(writer *zip.Writer, path string) error {
|
||||||
|
in, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
|
||||||
|
info, err := in.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
header, err := zip.FileInfoHeader(info)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
header.Name = filepath.Base(path)
|
||||||
|
header.Method = zip.Deflate
|
||||||
|
|
||||||
|
out, err := writer.CreateHeader(header)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(out, in)
|
||||||
|
return err
|
||||||
|
}
|
||||||
83
internal/appstate/backup_test.go
Normal file
83
internal/appstate/backup_test.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package appstate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
|
||||||
|
temp := t.TempDir()
|
||||||
|
dbPath := filepath.Join(temp, "qfs.db")
|
||||||
|
cfgPath := filepath.Join(temp, "config.yaml")
|
||||||
|
|
||||||
|
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
|
||||||
|
t.Fatalf("write db: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prevNow := backupNow
|
||||||
|
defer func() { backupNow = prevNow }()
|
||||||
|
backupNow = func() time.Time { return time.Date(2026, 2, 11, 10, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
created, err := EnsureRotatingLocalBackup(dbPath, cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("backup: %v", err)
|
||||||
|
}
|
||||||
|
if len(created) == 0 {
|
||||||
|
t.Fatalf("expected backup to be created")
|
||||||
|
}
|
||||||
|
|
||||||
|
dailyArchive := filepath.Join(temp, "backups", "daily", "qfs-backp-2026-02-11.zip")
|
||||||
|
if _, err := os.Stat(dailyArchive); err != nil {
|
||||||
|
t.Fatalf("daily archive missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backupNow = func() time.Time { return time.Date(2026, 2, 12, 10, 0, 0, 0, time.UTC) }
|
||||||
|
created, err = EnsureRotatingLocalBackup(dbPath, cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("backup rotate: %v", err)
|
||||||
|
}
|
||||||
|
if len(created) == 0 {
|
||||||
|
t.Fatalf("expected backup to be created for new day")
|
||||||
|
}
|
||||||
|
|
||||||
|
dailyArchive = filepath.Join(temp, "backups", "daily", "qfs-backp-2026-02-12.zip")
|
||||||
|
if _, err := os.Stat(dailyArchive); err != nil {
|
||||||
|
t.Fatalf("daily archive missing after rotate: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureRotatingLocalBackupEnvControls(t *testing.T) {
|
||||||
|
temp := t.TempDir()
|
||||||
|
dbPath := filepath.Join(temp, "qfs.db")
|
||||||
|
cfgPath := filepath.Join(temp, "config.yaml")
|
||||||
|
|
||||||
|
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
|
||||||
|
t.Fatalf("write db: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backupRoot := filepath.Join(temp, "custom_backups")
|
||||||
|
t.Setenv(envBackupDir, backupRoot)
|
||||||
|
|
||||||
|
if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
|
||||||
|
t.Fatalf("backup with env: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(backupRoot, "daily", "meta.json")); err != nil {
|
||||||
|
t.Fatalf("expected backup in custom dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv(envBackupDisable, "1")
|
||||||
|
if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
|
||||||
|
t.Fatalf("backup disabled: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(backupRoot, "daily", "meta.json")); err != nil {
|
||||||
|
t.Fatalf("backup should remain from previous run: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
124
internal/article/categories.go
Normal file
124
internal/article/categories.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package article
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrMissingCategoryForLot is returned when a lot has no category in local_pricelist_items.lot_category.
|
||||||
|
var ErrMissingCategoryForLot = errors.New("missing_category_for_lot")
|
||||||
|
|
||||||
|
type MissingCategoryForLotError struct {
|
||||||
|
LotName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MissingCategoryForLotError) Error() string {
|
||||||
|
if e == nil || strings.TrimSpace(e.LotName) == "" {
|
||||||
|
return ErrMissingCategoryForLot.Error()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s: %s", ErrMissingCategoryForLot.Error(), e.LotName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MissingCategoryForLotError) Unwrap() error {
|
||||||
|
return ErrMissingCategoryForLot
|
||||||
|
}
|
||||||
|
|
||||||
|
type Group string
|
||||||
|
|
||||||
|
const (
|
||||||
|
GroupCPU Group = "CPU"
|
||||||
|
GroupMEM Group = "MEM"
|
||||||
|
GroupGPU Group = "GPU"
|
||||||
|
GroupDISK Group = "DISK"
|
||||||
|
GroupNET Group = "NET"
|
||||||
|
GroupPSU Group = "PSU"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GroupForLotCategory maps pricelist lot_category codes into article groups.
|
||||||
|
// Unknown/unrelated categories return ok=false.
|
||||||
|
func GroupForLotCategory(cat string) (group Group, ok bool) {
|
||||||
|
c := strings.ToUpper(strings.TrimSpace(cat))
|
||||||
|
switch c {
|
||||||
|
case "CPU":
|
||||||
|
return GroupCPU, true
|
||||||
|
case "MEM":
|
||||||
|
return GroupMEM, true
|
||||||
|
case "GPU":
|
||||||
|
return GroupGPU, true
|
||||||
|
case "M2", "SSD", "HDD", "EDSFF", "HHHL":
|
||||||
|
return GroupDISK, true
|
||||||
|
case "NIC", "HCA", "DPU":
|
||||||
|
return GroupNET, true
|
||||||
|
case "HBA":
|
||||||
|
return GroupNET, true
|
||||||
|
case "PSU", "PS":
|
||||||
|
return GroupPSU, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveLotCategoriesStrict resolves categories for lotNames using local_pricelist_items.lot_category
|
||||||
|
// for a given server pricelist id. If any lot is missing or has empty category, returns an error.
|
||||||
|
func ResolveLotCategoriesStrict(local *localdb.LocalDB, serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
||||||
|
if local == nil {
|
||||||
|
return nil, fmt.Errorf("local db is nil")
|
||||||
|
}
|
||||||
|
cats, err := local.GetLocalLotCategoriesByServerPricelistID(serverPricelistID, lotNames)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
missing := make([]string, 0)
|
||||||
|
for _, lot := range lotNames {
|
||||||
|
cat := strings.TrimSpace(cats[lot])
|
||||||
|
if cat == "" {
|
||||||
|
missing = append(missing, lot)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cats[lot] = cat
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
fallback, err := local.GetLocalComponentCategoriesByLotNames(missing)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, lot := range missing {
|
||||||
|
if cat := strings.TrimSpace(fallback[lot]); cat != "" {
|
||||||
|
cats[lot] = cat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, lot := range missing {
|
||||||
|
if strings.TrimSpace(cats[lot]) == "" {
|
||||||
|
return nil, &MissingCategoryForLotError{LotName: lot}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeServerModel produces a stable article segment for the server model.
|
||||||
|
func NormalizeServerModel(model string) string {
|
||||||
|
trimmed := strings.TrimSpace(model)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
upper := strings.ToUpper(trimmed)
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range upper {
|
||||||
|
if r >= 'A' && r <= 'Z' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r >= '0' && r <= '9' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r == '.' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
98
internal/article/categories_test.go
Normal file
98
internal/article/categories_test.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package article
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveLotCategoriesStrict_MissingCategoryReturnsError(t *testing.T) {
|
||||||
|
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init local db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||||
|
ServerID: 1,
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "S-2026-02-11-001",
|
||||||
|
Name: "test",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
SyncedAt: time.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save local pricelist: %v", err)
|
||||||
|
}
|
||||||
|
localPL, err := local.GetLocalPricelistByServerID(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get local pricelist: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||||
|
{PricelistID: localPL.ID, LotName: "CPU_A", LotCategory: "", Price: 10},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save local items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ResolveLotCategoriesStrict(local, 1, []string{"CPU_A"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrMissingCategoryForLot) {
|
||||||
|
t.Fatalf("expected ErrMissingCategoryForLot, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveLotCategoriesStrict_FallbackToLocalComponents(t *testing.T) {
|
||||||
|
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init local db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||||
|
ServerID: 2,
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "S-2026-02-11-002",
|
||||||
|
Name: "test",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
SyncedAt: time.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save local pricelist: %v", err)
|
||||||
|
}
|
||||||
|
localPL, err := local.GetLocalPricelistByServerID(2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get local pricelist: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||||
|
{PricelistID: localPL.ID, LotName: "CPU_B", LotCategory: "", Price: 10},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save local items: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.DB().Create(&localdb.LocalComponent{
|
||||||
|
LotName: "CPU_B",
|
||||||
|
Category: "CPU",
|
||||||
|
LotDescription: "cpu",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("save local components: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cats, err := ResolveLotCategoriesStrict(local, 2, []string{"CPU_B"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected fallback, got error: %v", err)
|
||||||
|
}
|
||||||
|
if cats["CPU_B"] != "CPU" {
|
||||||
|
t.Fatalf("expected CPU, got %q", cats["CPU_B"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupForLotCategory(t *testing.T) {
|
||||||
|
if g, ok := GroupForLotCategory("cpu"); !ok || g != GroupCPU {
|
||||||
|
t.Fatalf("expected cpu -> GroupCPU")
|
||||||
|
}
|
||||||
|
if g, ok := GroupForLotCategory("SFP"); ok || g != "" {
|
||||||
|
t.Fatalf("expected SFP to be excluded")
|
||||||
|
}
|
||||||
|
}
|
||||||
602
internal/article/generator.go
Normal file
602
internal/article/generator.go
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
package article
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BuildOptions struct {
|
||||||
|
ServerModel string
|
||||||
|
SupportCode string
|
||||||
|
ServerPricelist *uint
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuildResult struct {
|
||||||
|
Article string
|
||||||
|
Warnings []string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
reMemGiB = regexp.MustCompile(`(?i)(\d+)\s*(GB|G)`)
|
||||||
|
reMemTiB = regexp.MustCompile(`(?i)(\d+)\s*(TB|T)`)
|
||||||
|
reCapacityT = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)T`)
|
||||||
|
reCapacityG = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)G`)
|
||||||
|
rePortSpeed = regexp.MustCompile(`(?i)(\d+)p(\d+)(GbE|G)`)
|
||||||
|
rePortFC = regexp.MustCompile(`(?i)(\d+)pFC(\d+)`)
|
||||||
|
reWatts = regexp.MustCompile(`(?i)(\d{3,5})\s*W`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions) (BuildResult, error) {
|
||||||
|
segments := make([]string, 0, 8)
|
||||||
|
warnings := make([]string, 0)
|
||||||
|
|
||||||
|
model := NormalizeServerModel(opts.ServerModel)
|
||||||
|
if model == "" {
|
||||||
|
return BuildResult{}, fmt.Errorf("server_model required")
|
||||||
|
}
|
||||||
|
segments = append(segments, model)
|
||||||
|
|
||||||
|
lotNames := make([]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
lotNames = append(lotNames, it.LotName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.ServerPricelist == nil || *opts.ServerPricelist == 0 {
|
||||||
|
return BuildResult{}, fmt.Errorf("pricelist_id required for article")
|
||||||
|
}
|
||||||
|
|
||||||
|
cats, err := ResolveLotCategoriesStrict(local, *opts.ServerPricelist, lotNames)
|
||||||
|
if err != nil {
|
||||||
|
return BuildResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cpuSeg := buildCPUSegment(items, cats)
|
||||||
|
if cpuSeg != "" {
|
||||||
|
segments = append(segments, cpuSeg)
|
||||||
|
}
|
||||||
|
memSeg, memWarn := buildMemSegment(items, cats)
|
||||||
|
if memWarn != "" {
|
||||||
|
warnings = append(warnings, memWarn)
|
||||||
|
}
|
||||||
|
if memSeg != "" {
|
||||||
|
segments = append(segments, memSeg)
|
||||||
|
}
|
||||||
|
gpuSeg := buildGPUSegment(items, cats)
|
||||||
|
if gpuSeg != "" {
|
||||||
|
segments = append(segments, gpuSeg)
|
||||||
|
}
|
||||||
|
diskSeg, diskWarn := buildDiskSegment(items, cats)
|
||||||
|
if diskWarn != "" {
|
||||||
|
warnings = append(warnings, diskWarn)
|
||||||
|
}
|
||||||
|
if diskSeg != "" {
|
||||||
|
segments = append(segments, diskSeg)
|
||||||
|
}
|
||||||
|
netSeg, netWarn := buildNetSegment(items, cats)
|
||||||
|
if netWarn != "" {
|
||||||
|
warnings = append(warnings, netWarn)
|
||||||
|
}
|
||||||
|
if netSeg != "" {
|
||||||
|
segments = append(segments, netSeg)
|
||||||
|
}
|
||||||
|
psuSeg, psuWarn := buildPSUSegment(items, cats)
|
||||||
|
if psuWarn != "" {
|
||||||
|
warnings = append(warnings, psuWarn)
|
||||||
|
}
|
||||||
|
if psuSeg != "" {
|
||||||
|
segments = append(segments, psuSeg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(opts.SupportCode) != "" {
|
||||||
|
code := strings.TrimSpace(opts.SupportCode)
|
||||||
|
if !isSupportCodeValid(code) {
|
||||||
|
return BuildResult{}, fmt.Errorf("invalid_support_code")
|
||||||
|
}
|
||||||
|
segments = append(segments, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
article := strings.Join(segments, "-")
|
||||||
|
if len([]rune(article)) > 80 {
|
||||||
|
article = compressArticle(segments)
|
||||||
|
warnings = append(warnings, "compressed")
|
||||||
|
}
|
||||||
|
if len([]rune(article)) > 80 {
|
||||||
|
return BuildResult{}, fmt.Errorf("article_overflow")
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildResult{Article: article, Warnings: warnings}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSupportCodeValid(code string) bool {
|
||||||
|
if len(code) < 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !strings.Contains(code, "y") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parts := strings.Split(code, "y")
|
||||||
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range parts[0] {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch parts[1] {
|
||||||
|
case "W", "B", "S", "P":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCPUSegment(items []models.ConfigItem, cats map[string]string) string {
|
||||||
|
type agg struct {
|
||||||
|
qty int
|
||||||
|
}
|
||||||
|
models := map[string]*agg{}
|
||||||
|
for _, it := range items {
|
||||||
|
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||||
|
if !ok || group != GroupCPU {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
model := parseCPUModel(it.LotName)
|
||||||
|
if model == "" {
|
||||||
|
model = "UNK"
|
||||||
|
}
|
||||||
|
if _, ok := models[model]; !ok {
|
||||||
|
models[model] = &agg{}
|
||||||
|
}
|
||||||
|
models[model].qty += it.Quantity
|
||||||
|
}
|
||||||
|
if len(models) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(models))
|
||||||
|
for model, a := range models {
|
||||||
|
parts = append(parts, fmt.Sprintf("%dx%s", a.qty, model))
|
||||||
|
}
|
||||||
|
sort.Strings(parts)
|
||||||
|
return strings.Join(parts, "+")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMemSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
|
||||||
|
totalGiB := 0
|
||||||
|
for _, it := range items {
|
||||||
|
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||||
|
if !ok || group != GroupMEM {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
per := parseMemGiB(it.LotName)
|
||||||
|
if per <= 0 {
|
||||||
|
return "", "mem_unknown"
|
||||||
|
}
|
||||||
|
totalGiB += per * it.Quantity
|
||||||
|
}
|
||||||
|
if totalGiB == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
if totalGiB%1024 == 0 {
|
||||||
|
return fmt.Sprintf("%dT", totalGiB/1024), ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dG", totalGiB), ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildGPUSegment(items []models.ConfigItem, cats map[string]string) string {
|
||||||
|
models := map[string]int{}
|
||||||
|
for _, it := range items {
|
||||||
|
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||||
|
if !ok || group != GroupGPU {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
model := parseGPUModel(it.LotName)
|
||||||
|
if model == "" {
|
||||||
|
model = "UNK"
|
||||||
|
}
|
||||||
|
models[model] += it.Quantity
|
||||||
|
}
|
||||||
|
if len(models) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(models))
|
||||||
|
for model, qty := range models {
|
||||||
|
parts = append(parts, fmt.Sprintf("%dx%s", qty, model))
|
||||||
|
}
|
||||||
|
sort.Strings(parts)
|
||||||
|
return strings.Join(parts, "+")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDiskSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
|
||||||
|
type key struct {
|
||||||
|
t string
|
||||||
|
c string
|
||||||
|
}
|
||||||
|
groupQty := map[key]int{}
|
||||||
|
warn := ""
|
||||||
|
for _, it := range items {
|
||||||
|
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||||
|
if !ok || group != GroupDISK {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
capToken := parseCapacity(it.LotName)
|
||||||
|
if capToken == "" {
|
||||||
|
warn = "disk_unknown"
|
||||||
|
}
|
||||||
|
typeCode := diskTypeCode(cats[it.LotName], it.LotName)
|
||||||
|
k := key{t: typeCode, c: capToken}
|
||||||
|
groupQty[k] += it.Quantity
|
||||||
|
}
|
||||||
|
if len(groupQty) == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(groupQty))
|
||||||
|
for k, qty := range groupQty {
|
||||||
|
if k.c == "" {
|
||||||
|
parts = append(parts, fmt.Sprintf("%dx%s", qty, k.t))
|
||||||
|
} else {
|
||||||
|
parts = append(parts, fmt.Sprintf("%dx%s%s", qty, k.c, k.t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(parts)
|
||||||
|
return strings.Join(parts, "+"), warn
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildNetSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
|
||||||
|
groupQty := map[string]int{}
|
||||||
|
warn := ""
|
||||||
|
for _, it := range items {
|
||||||
|
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||||
|
if !ok || group != GroupNET {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
profile := parsePortSpeed(it.LotName)
|
||||||
|
if profile == "" {
|
||||||
|
profile = "UNKNET"
|
||||||
|
warn = "net_unknown"
|
||||||
|
}
|
||||||
|
groupQty[profile] += it.Quantity
|
||||||
|
}
|
||||||
|
if len(groupQty) == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(groupQty))
|
||||||
|
for profile, qty := range groupQty {
|
||||||
|
parts = append(parts, fmt.Sprintf("%dx%s", qty, profile))
|
||||||
|
}
|
||||||
|
sort.Strings(parts)
|
||||||
|
return strings.Join(parts, "+"), warn
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPSUSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
|
||||||
|
groupQty := map[string]int{}
|
||||||
|
warn := ""
|
||||||
|
for _, it := range items {
|
||||||
|
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||||
|
if !ok || group != GroupPSU {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rating := parseWatts(it.LotName)
|
||||||
|
if rating == "" {
|
||||||
|
rating = "UNKPSU"
|
||||||
|
warn = "psu_unknown"
|
||||||
|
}
|
||||||
|
groupQty[rating] += it.Quantity
|
||||||
|
}
|
||||||
|
if len(groupQty) == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(groupQty))
|
||||||
|
for rating, qty := range groupQty {
|
||||||
|
parts = append(parts, fmt.Sprintf("%dx%s", qty, rating))
|
||||||
|
}
|
||||||
|
sort.Strings(parts)
|
||||||
|
return strings.Join(parts, "+"), warn
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeModelToken(lotName string) string {
|
||||||
|
if idx := strings.Index(lotName, "_"); idx >= 0 && idx+1 < len(lotName) {
|
||||||
|
lotName = lotName[idx+1:]
|
||||||
|
}
|
||||||
|
parts := strings.Split(lotName, "_")
|
||||||
|
token := parts[len(parts)-1]
|
||||||
|
return strings.ToUpper(strings.TrimSpace(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCPUModel(lotName string) string {
|
||||||
|
parts := strings.Split(lotName, "_")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
last := strings.ToUpper(strings.TrimSpace(parts[len(parts)-1]))
|
||||||
|
if last != "" {
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizeModelToken(lotName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGPUModel(lotName string) string {
|
||||||
|
upper := strings.ToUpper(lotName)
|
||||||
|
if idx := strings.Index(upper, "GPU_"); idx >= 0 {
|
||||||
|
upper = upper[idx+4:]
|
||||||
|
}
|
||||||
|
parts := strings.Split(upper, "_")
|
||||||
|
model := ""
|
||||||
|
mem := ""
|
||||||
|
for i, p := range parts {
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch p {
|
||||||
|
case "NV", "NVIDIA", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX":
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
if strings.Contains(p, "GB") {
|
||||||
|
mem = p
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if model == "" && (i > 0) {
|
||||||
|
model = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if model != "" && mem != "" {
|
||||||
|
return model + "_" + mem
|
||||||
|
}
|
||||||
|
if model != "" {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
return normalizeModelToken(lotName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMemGiB(lotName string) int {
|
||||||
|
if m := reMemTiB.FindStringSubmatch(lotName); len(m) == 3 {
|
||||||
|
return atoi(m[1]) * 1024
|
||||||
|
}
|
||||||
|
if m := reMemGiB.FindStringSubmatch(lotName); len(m) == 3 {
|
||||||
|
return atoi(m[1])
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCapacity(lotName string) string {
|
||||||
|
if m := reCapacityT.FindStringSubmatch(lotName); len(m) == 2 {
|
||||||
|
return normalizeTToken(strings.ReplaceAll(m[1], ",", ".")) + "T"
|
||||||
|
}
|
||||||
|
if m := reCapacityG.FindStringSubmatch(lotName); len(m) == 2 {
|
||||||
|
return normalizeNumberToken(strings.ReplaceAll(m[1], ",", ".")) + "G"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func diskTypeCode(cat string, lotName string) string {
|
||||||
|
c := strings.ToUpper(strings.TrimSpace(cat))
|
||||||
|
if c == "M2" {
|
||||||
|
return "M2"
|
||||||
|
}
|
||||||
|
upper := strings.ToUpper(lotName)
|
||||||
|
if strings.Contains(upper, "NVME") {
|
||||||
|
return "NV"
|
||||||
|
}
|
||||||
|
if strings.Contains(upper, "SAS") {
|
||||||
|
return "SAS"
|
||||||
|
}
|
||||||
|
if strings.Contains(upper, "SATA") {
|
||||||
|
return "SAT"
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePortSpeed(lotName string) string {
|
||||||
|
if m := rePortSpeed.FindStringSubmatch(lotName); len(m) == 4 {
|
||||||
|
return fmt.Sprintf("%sp%sG", m[1], m[2])
|
||||||
|
}
|
||||||
|
if m := rePortFC.FindStringSubmatch(lotName); len(m) == 3 {
|
||||||
|
return fmt.Sprintf("%spFC%s", m[1], m[2])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWatts(lotName string) string {
|
||||||
|
if m := reWatts.FindStringSubmatch(lotName); len(m) == 2 {
|
||||||
|
w := atoi(m[1])
|
||||||
|
if w >= 1000 {
|
||||||
|
kw := fmt.Sprintf("%.1f", float64(w)/1000.0)
|
||||||
|
kw = strings.TrimSuffix(kw, ".0")
|
||||||
|
return fmt.Sprintf("%skW", kw)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dW", w)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeNumberToken(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
raw = strings.TrimLeft(raw, "0")
|
||||||
|
if raw == "" || raw[0] == '.' {
|
||||||
|
raw = "0" + raw
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTToken(raw string) string {
|
||||||
|
raw = normalizeNumberToken(raw)
|
||||||
|
parts := strings.SplitN(raw, ".", 2)
|
||||||
|
intPart := parts[0]
|
||||||
|
frac := ""
|
||||||
|
if len(parts) == 2 {
|
||||||
|
frac = parts[1]
|
||||||
|
}
|
||||||
|
if frac == "" {
|
||||||
|
frac = "0"
|
||||||
|
}
|
||||||
|
if len(intPart) >= 2 {
|
||||||
|
return intPart + "." + frac
|
||||||
|
}
|
||||||
|
if len(frac) > 1 {
|
||||||
|
frac = frac[:1]
|
||||||
|
}
|
||||||
|
return intPart + "." + frac
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoi(v string) int {
|
||||||
|
n := 0
|
||||||
|
for _, r := range v {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n = n*10 + int(r-'0')
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func compressArticle(segments []string) string {
|
||||||
|
if len(segments) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
normalized := make([]string, 0, len(segments))
|
||||||
|
for _, s := range segments {
|
||||||
|
normalized = append(normalized, strings.ReplaceAll(s, "GbE", "G"))
|
||||||
|
}
|
||||||
|
segments = normalized
|
||||||
|
article := strings.Join(segments, "-")
|
||||||
|
if len([]rune(article)) <= 80 {
|
||||||
|
return article
|
||||||
|
}
|
||||||
|
|
||||||
|
// segment order: model, cpu, mem, gpu, disk, net, psu, support
|
||||||
|
index := func(i int) (int, bool) {
|
||||||
|
if i >= 0 && i < len(segments) {
|
||||||
|
return i, true
|
||||||
|
}
|
||||||
|
return -1, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) remove PSU
|
||||||
|
if i, ok := index(6); ok {
|
||||||
|
segments = append(segments[:i], segments[i+1:]...)
|
||||||
|
article = strings.Join(segments, "-")
|
||||||
|
if len([]rune(article)) <= 80 {
|
||||||
|
return article
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) compress NET/HBA/HCA
|
||||||
|
if i, ok := index(5); ok {
|
||||||
|
segments[i] = compressNetSegment(segments[i])
|
||||||
|
article = strings.Join(segments, "-")
|
||||||
|
if len([]rune(article)) <= 80 {
|
||||||
|
return article
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) compress DISK
|
||||||
|
if i, ok := index(4); ok {
|
||||||
|
segments[i] = compressDiskSegment(segments[i])
|
||||||
|
article = strings.Join(segments, "-")
|
||||||
|
if len([]rune(article)) <= 80 {
|
||||||
|
return article
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) compress GPU to vendor only (GPU_NV)
|
||||||
|
if i, ok := index(3); ok {
|
||||||
|
segments[i] = compressGPUSegment(segments[i])
|
||||||
|
}
|
||||||
|
return strings.Join(segments, "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
func compressNetSegment(seg string) string {
|
||||||
|
if seg == "" {
|
||||||
|
return seg
|
||||||
|
}
|
||||||
|
parts := strings.Split(seg, "+")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
qty := "1"
|
||||||
|
profile := p
|
||||||
|
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
|
||||||
|
qty = x[0]
|
||||||
|
profile = x[1]
|
||||||
|
}
|
||||||
|
upper := strings.ToUpper(profile)
|
||||||
|
label := "NIC"
|
||||||
|
if strings.Contains(upper, "FC") {
|
||||||
|
label = "HBA"
|
||||||
|
} else if strings.Contains(upper, "HCA") || strings.Contains(upper, "IB") {
|
||||||
|
label = "HCA"
|
||||||
|
}
|
||||||
|
out = append(out, fmt.Sprintf("%sx%s", qty, label))
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return seg
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return strings.Join(out, "+")
|
||||||
|
}
|
||||||
|
|
||||||
|
func compressDiskSegment(seg string) string {
|
||||||
|
if seg == "" {
|
||||||
|
return seg
|
||||||
|
}
|
||||||
|
parts := strings.Split(seg, "+")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
qty := "1"
|
||||||
|
spec := p
|
||||||
|
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
|
||||||
|
qty = x[0]
|
||||||
|
spec = x[1]
|
||||||
|
}
|
||||||
|
upper := strings.ToUpper(spec)
|
||||||
|
label := "DSK"
|
||||||
|
for _, t := range []string{"M2", "NV", "SAS", "SAT", "SSD", "HDD", "EDS", "HHH"} {
|
||||||
|
if strings.Contains(upper, t) {
|
||||||
|
label = t
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, fmt.Sprintf("%sx%s", qty, label))
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return seg
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return strings.Join(out, "+")
|
||||||
|
}
|
||||||
|
|
||||||
|
func compressGPUSegment(seg string) string {
|
||||||
|
if seg == "" {
|
||||||
|
return seg
|
||||||
|
}
|
||||||
|
parts := strings.Split(seg, "+")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
qty := "1"
|
||||||
|
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
|
||||||
|
qty = x[0]
|
||||||
|
}
|
||||||
|
out = append(out, fmt.Sprintf("%sxGPU_NV", qty))
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return seg
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return strings.Join(out, "+")
|
||||||
|
}
|
||||||
66
internal/article/generator_test.go
Normal file
66
internal/article/generator_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package article
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuild_ParsesNetAndPSU(t *testing.T) {
|
||||||
|
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init local db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||||
|
ServerID: 1,
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "S-2026-02-11-001",
|
||||||
|
Name: "test",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
SyncedAt: time.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save local pricelist: %v", err)
|
||||||
|
}
|
||||||
|
localPL, err := local.GetLocalPricelistByServerID(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get local pricelist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||||
|
{PricelistID: localPL.ID, LotName: "NIC_2p25G_MCX512A-AC", LotCategory: "NIC", Price: 1},
|
||||||
|
{PricelistID: localPL.ID, LotName: "HBA_2pFC32_Gen6", LotCategory: "HBA", Price: 1},
|
||||||
|
{PricelistID: localPL.ID, LotName: "PS_1000W_Platinum", LotCategory: "PS", Price: 1},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save local items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items := models.ConfigItems{
|
||||||
|
{LotName: "NIC_2p25G_MCX512A-AC", Quantity: 1},
|
||||||
|
{LotName: "HBA_2pFC32_Gen6", Quantity: 1},
|
||||||
|
{LotName: "PS_1000W_Platinum", Quantity: 2},
|
||||||
|
}
|
||||||
|
result, err := Build(local, items, BuildOptions{
|
||||||
|
ServerModel: "DL380GEN11",
|
||||||
|
SupportCode: "1yW",
|
||||||
|
ServerPricelist: &localPL.ServerID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build article: %v", err)
|
||||||
|
}
|
||||||
|
if result.Article == "" {
|
||||||
|
t.Fatalf("expected article to be non-empty")
|
||||||
|
}
|
||||||
|
if contains(result.Article, "UNKNET") || contains(result.Article, "UNKPSU") {
|
||||||
|
t.Fatalf("unexpected UNK in article: %s", result.Article)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
return strings.Contains(s, sub)
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ type Config struct {
|
|||||||
Alerts AlertsConfig `yaml:"alerts"`
|
Alerts AlertsConfig `yaml:"alerts"`
|
||||||
Notifications NotificationsConfig `yaml:"notifications"`
|
Notifications NotificationsConfig `yaml:"notifications"`
|
||||||
Logging LoggingConfig `yaml:"logging"`
|
Logging LoggingConfig `yaml:"logging"`
|
||||||
|
Backup BackupConfig `yaml:"backup"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
@@ -101,6 +102,10 @@ type LoggingConfig struct {
|
|||||||
FilePath string `yaml:"file_path"`
|
FilePath string `yaml:"file_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BackupConfig struct {
|
||||||
|
Time string `yaml:"time"`
|
||||||
|
}
|
||||||
|
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -182,6 +187,10 @@ func (c *Config) setDefaults() {
|
|||||||
if c.Logging.Output == "" {
|
if c.Logging.Output == "" {
|
||||||
c.Logging.Output = "stdout"
|
c.Logging.Output = "stdout"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.Backup.Time == "" {
|
||||||
|
c.Backup.Time = "00:00"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) Address() string {
|
func (c *Config) Address() string {
|
||||||
|
|||||||
@@ -3,34 +3,40 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ExportHandler struct {
|
type ExportHandler struct {
|
||||||
exportService *services.ExportService
|
exportService *services.ExportService
|
||||||
configService services.ConfigurationGetter
|
configService services.ConfigurationGetter
|
||||||
componentService *services.ComponentService
|
projectService *services.ProjectService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExportHandler(
|
func NewExportHandler(
|
||||||
exportService *services.ExportService,
|
exportService *services.ExportService,
|
||||||
configService services.ConfigurationGetter,
|
configService services.ConfigurationGetter,
|
||||||
componentService *services.ComponentService,
|
projectService *services.ProjectService,
|
||||||
) *ExportHandler {
|
) *ExportHandler {
|
||||||
return &ExportHandler{
|
return &ExportHandler{
|
||||||
exportService: exportService,
|
exportService: exportService,
|
||||||
configService: configService,
|
configService: configService,
|
||||||
componentService: componentService,
|
projectService: projectService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExportRequest struct {
|
type ExportRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
ProjectName string `json:"project_name"`
|
ProjectName string `json:"project_name"`
|
||||||
|
ProjectUUID string `json:"project_uuid"`
|
||||||
|
Article string `json:"article"`
|
||||||
|
ServerCount int `json:"server_count"`
|
||||||
|
PricelistID *uint `json:"pricelist_id"`
|
||||||
Items []struct {
|
Items []struct {
|
||||||
LotName string `json:"lot_name" binding:"required"`
|
LotName string `json:"lot_name" binding:"required"`
|
||||||
Quantity int `json:"quantity" binding:"required,min=1"`
|
Quantity int `json:"quantity" binding:"required,min=1"`
|
||||||
@@ -49,17 +55,30 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
|||||||
data := h.buildExportData(&req)
|
data := h.buildExportData(&req)
|
||||||
|
|
||||||
// Validate before streaming (can return JSON error)
|
// Validate before streaming (can return JSON error)
|
||||||
if len(data.Items) == 0 {
|
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set headers before streaming
|
// Get project code for filename
|
||||||
projectName := req.ProjectName
|
projectCode := req.ProjectName // legacy field: may contain code from frontend
|
||||||
if projectName == "" {
|
if projectCode == "" && req.ProjectUUID != "" {
|
||||||
projectName = req.Name
|
username := middleware.GetUsername(c)
|
||||||
|
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
|
||||||
|
projectCode = project.Code
|
||||||
|
}
|
||||||
}
|
}
|
||||||
filename := fmt.Sprintf("%s (%s) %s BOM.csv", time.Now().Format("2006-01-02"), projectName, req.Name)
|
if projectCode == "" {
|
||||||
|
projectCode = req.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers before streaming
|
||||||
|
exportDate := data.CreatedAt
|
||||||
|
articleSegment := sanitizeFilenameSegment(req.Article)
|
||||||
|
if articleSegment == "" {
|
||||||
|
articleSegment = "BOM"
|
||||||
|
}
|
||||||
|
filename := fmt.Sprintf("%s (%s) %s %s.csv", exportDate.Format("2006-01-02"), projectCode, req.Name, articleSegment)
|
||||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
|
|
||||||
@@ -70,43 +89,50 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
|
// buildExportData converts an ExportRequest into a ProjectExportData using a temporary Configuration model
|
||||||
items := make([]services.ExportItem, len(req.Items))
|
// so that ExportService.ConfigToExportData can resolve categories via localDB.
|
||||||
var total float64
|
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ProjectExportData {
|
||||||
|
configItems := make(models.ConfigItems, len(req.Items))
|
||||||
for i, item := range req.Items {
|
for i, item := range req.Items {
|
||||||
itemTotal := item.UnitPrice * float64(item.Quantity)
|
configItems[i] = models.ConfigItem{
|
||||||
|
LotName: item.LotName,
|
||||||
// Получаем информацию о компоненте для заполнения категории и описания
|
Quantity: item.Quantity,
|
||||||
componentView, err := h.componentService.GetByLotName(item.LotName)
|
UnitPrice: item.UnitPrice,
|
||||||
if err != nil {
|
|
||||||
// Если не удалось получить информацию о компоненте, используем только основные данные
|
|
||||||
items[i] = services.ExportItem{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: item.UnitPrice,
|
|
||||||
TotalPrice: itemTotal,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
items[i] = services.ExportItem{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Description: componentView.Description,
|
|
||||||
Category: componentView.Category,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: item.UnitPrice,
|
|
||||||
TotalPrice: itemTotal,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
total += itemTotal
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &services.ExportData{
|
serverCount := req.ServerCount
|
||||||
Name: req.Name,
|
if serverCount < 1 {
|
||||||
Items: items,
|
serverCount = 1
|
||||||
Total: total,
|
|
||||||
Notes: req.Notes,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg := &models.Configuration{
|
||||||
|
Article: req.Article,
|
||||||
|
ServerCount: serverCount,
|
||||||
|
PricelistID: req.PricelistID,
|
||||||
|
Items: configItems,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.exportService.ConfigToExportData(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeFilenameSegment(value string) string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
"/", "_",
|
||||||
|
"\\", "_",
|
||||||
|
":", "_",
|
||||||
|
"*", "_",
|
||||||
|
"?", "_",
|
||||||
|
"\"", "_",
|
||||||
|
"<", "_",
|
||||||
|
">", "_",
|
||||||
|
"|", "_",
|
||||||
|
)
|
||||||
|
return strings.TrimSpace(replacer.Replace(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
||||||
@@ -120,17 +146,29 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data := h.exportService.ConfigToExportData(config, h.componentService)
|
data := h.exportService.ConfigToExportData(config)
|
||||||
|
|
||||||
// Validate before streaming (can return JSON error)
|
// Validate before streaming (can return JSON error)
|
||||||
if len(data.Items) == 0 {
|
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get project code for filename
|
||||||
|
projectCode := config.Name // fallback: use config name if no project
|
||||||
|
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
|
||||||
|
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, username); err == nil && project != nil {
|
||||||
|
projectCode = project.Code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Set headers before streaming
|
// Set headers before streaming
|
||||||
// For config export, use config name for both project and quotation name
|
// Use price update time if available, otherwise creation time
|
||||||
filename := fmt.Sprintf("%s (%s) %s BOM.csv", config.CreatedAt.Format("2006-01-02"), config.Name, config.Name)
|
exportDate := config.CreatedAt
|
||||||
|
if config.PriceUpdatedAt != nil {
|
||||||
|
exportDate = *config.PriceUpdatedAt
|
||||||
|
}
|
||||||
|
filename := fmt.Sprintf("%s (%s) %s BOM.csv", exportDate.Format("2006-01-02"), projectCode, config.Name)
|
||||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
|
|
||||||
@@ -140,3 +178,38 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportProjectCSV exports all active configurations of a project as a single CSV file.
|
||||||
|
func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
|
||||||
|
username := middleware.GetUsername(c)
|
||||||
|
projectUUID := c.Param("uuid")
|
||||||
|
|
||||||
|
project, err := h.projectService.GetByUUID(projectUUID, username)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.projectService.ListConfigurations(projectUUID, username, "active")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Configs) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := h.exportService.ProjectToExportData(result.Configs)
|
||||||
|
|
||||||
|
// Filename: YYYY-MM-DD (ProjectCode) BOM.csv
|
||||||
|
filename := fmt.Sprintf("%s (%s) BOM.csv", time.Now().Format("2006-01-02"), project.Code)
|
||||||
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
|
|
||||||
|
if err := h.exportService.ToCSV(c.Writer, data); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,15 +30,12 @@ func (m *mockConfigService) GetByUUID(uuid string, ownerUsername string) (*model
|
|||||||
func TestExportCSV_Success(t *testing.T) {
|
func TestExportCSV_Success(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
// Create a basic mock component service that doesn't panic
|
|
||||||
mockComponentService := &services.ComponentService{}
|
|
||||||
|
|
||||||
// Create handler with mocks
|
// Create handler with mocks
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
handler := NewExportHandler(
|
handler := NewExportHandler(
|
||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{},
|
&mockConfigService{},
|
||||||
mockComponentService,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create JSON request body
|
// Create JSON request body
|
||||||
@@ -87,7 +84,7 @@ func TestExportCSV_Success(t *testing.T) {
|
|||||||
|
|
||||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||||
actualBOM := responseBody[:3]
|
actualBOM := responseBody[:3]
|
||||||
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
if !bytes.Equal(actualBOM, expectedBOM) {
|
||||||
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,19 +97,19 @@ func TestExportCSV_Success(t *testing.T) {
|
|||||||
t.Errorf("Failed to parse CSV header: %v", err)
|
t.Errorf("Failed to parse CSV header: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(header) != 6 {
|
if len(header) != 8 {
|
||||||
t.Errorf("Expected 6 columns, got %d", len(header))
|
t.Errorf("Expected 8 columns, got %d", len(header))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExportCSV_InvalidRequest(t *testing.T) {
|
func TestExportCSV_InvalidRequest(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
handler := NewExportHandler(
|
handler := NewExportHandler(
|
||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{},
|
&mockConfigService{},
|
||||||
&services.ComponentService{},
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create invalid request (missing required field)
|
// Create invalid request (missing required field)
|
||||||
@@ -141,11 +138,11 @@ func TestExportCSV_InvalidRequest(t *testing.T) {
|
|||||||
func TestExportCSV_EmptyItems(t *testing.T) {
|
func TestExportCSV_EmptyItems(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
handler := NewExportHandler(
|
handler := NewExportHandler(
|
||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{},
|
&mockConfigService{},
|
||||||
&services.ComponentService{},
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create request with empty items array - should fail binding validation
|
// Create request with empty items array - should fail binding validation
|
||||||
@@ -182,11 +179,11 @@ func TestExportConfigCSV_Success(t *testing.T) {
|
|||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
handler := NewExportHandler(
|
handler := NewExportHandler(
|
||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{config: mockConfig},
|
&mockConfigService{config: mockConfig},
|
||||||
&services.ComponentService{},
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create HTTP request
|
// Create HTTP request
|
||||||
@@ -223,7 +220,7 @@ func TestExportConfigCSV_Success(t *testing.T) {
|
|||||||
|
|
||||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||||
actualBOM := responseBody[:3]
|
actualBOM := responseBody[:3]
|
||||||
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
if !bytes.Equal(actualBOM, expectedBOM) {
|
||||||
t.Errorf("UTF-8 BOM mismatch")
|
t.Errorf("UTF-8 BOM mismatch")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,11 +228,11 @@ func TestExportConfigCSV_Success(t *testing.T) {
|
|||||||
func TestExportConfigCSV_NotFound(t *testing.T) {
|
func TestExportConfigCSV_NotFound(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
handler := NewExportHandler(
|
handler := NewExportHandler(
|
||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{err: errors.New("config not found")},
|
&mockConfigService{err: errors.New("config not found")},
|
||||||
&services.ComponentService{},
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "/api/configs/nonexistent-uuid/export", nil)
|
req, _ := http.NewRequest("GET", "/api/configs/nonexistent-uuid/export", nil)
|
||||||
@@ -275,11 +272,11 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) {
|
|||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
handler := NewExportHandler(
|
handler := NewExportHandler(
|
||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{config: mockConfig},
|
&mockConfigService{config: mockConfig},
|
||||||
&services.ComponentService{},
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
|
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
|
||||||
|
|||||||
@@ -157,15 +157,11 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
resultItems := make([]gin.H, 0, len(items))
|
resultItems := make([]gin.H, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
category := ""
|
|
||||||
if parts := strings.SplitN(item.LotName, "_", 2); len(parts) > 0 {
|
|
||||||
category = parts[0]
|
|
||||||
}
|
|
||||||
resultItems = append(resultItems, gin.H{
|
resultItems = append(resultItems, gin.H{
|
||||||
"id": item.ID,
|
"id": item.ID,
|
||||||
"lot_name": item.LotName,
|
"lot_name": item.LotName,
|
||||||
"price": item.Price,
|
"price": item.Price,
|
||||||
"category": category,
|
"category": item.LotCategory,
|
||||||
"available_qty": item.AvailableQty,
|
"available_qty": item.AvailableQty,
|
||||||
"partnumbers": []string(item.Partnumbers),
|
"partnumbers": []string(item.Partnumbers),
|
||||||
})
|
})
|
||||||
|
|||||||
84
internal/handlers/pricelist_test.go
Normal file
84
internal/handlers/pricelist_test.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPricelistGetItems_ReturnsLotCategoryFromLocalPricelistItems(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init local db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||||
|
ServerID: 1,
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "S-2026-02-11-001",
|
||||||
|
Name: "test",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
SyncedAt: time.Now(),
|
||||||
|
IsUsed: false,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save local pricelist: %v", err)
|
||||||
|
}
|
||||||
|
localPL, err := local.GetLocalPricelistByServerID(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get local pricelist: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||||
|
{
|
||||||
|
PricelistID: localPL.ID,
|
||||||
|
LotName: "NO_UNDERSCORE_NAME",
|
||||||
|
LotCategory: "CPU",
|
||||||
|
Price: 10,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save local pricelist items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewPricelistHandler(local)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", "/api/pricelists/1/items?page=1&per_page=50", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = req
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: "1"}}
|
||||||
|
|
||||||
|
h.GetItems(c)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Items []struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
UnitPrice any `json:"price"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Items) != 1 {
|
||||||
|
t.Fatalf("expected 1 item, got %d", len(resp.Items))
|
||||||
|
}
|
||||||
|
if resp.Items[0].LotName != "NO_UNDERSCORE_NAME" {
|
||||||
|
t.Fatalf("expected lot_name NO_UNDERSCORE_NAME, got %q", resp.Items[0].LotName)
|
||||||
|
}
|
||||||
|
if resp.Items[0].Category != "CPU" {
|
||||||
|
t.Fatalf("expected category CPU, got %q", resp.Items[0].Category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -419,6 +419,26 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RepairPendingChanges attempts to repair errored pending changes
|
||||||
|
// POST /api/sync/repair
|
||||||
|
func (h *SyncHandler) RepairPendingChanges(c *gin.Context) {
|
||||||
|
repaired, remainingErrors, err := h.localDB.RepairPendingChanges()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("repair pending changes failed", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"repaired": repaired,
|
||||||
|
"remaining_errors": remainingErrors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// SyncInfoResponse represents sync information for the modal
|
// SyncInfoResponse represents sync information for the modal
|
||||||
type SyncInfoResponse struct {
|
type SyncInfoResponse struct {
|
||||||
// Connection
|
// Connection
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load each page template with base
|
// Load each page template with base
|
||||||
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html"}
|
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html"}
|
||||||
for _, page := range simplePages {
|
for _, page := range simplePages {
|
||||||
pagePath := filepath.Join(templatesPath, page)
|
pagePath := filepath.Join(templatesPath, page)
|
||||||
var tmpl *template.Template
|
var tmpl *template.Template
|
||||||
@@ -147,8 +147,8 @@ func (h *WebHandler) render(c *gin.Context, name string, data gin.H) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *WebHandler) Index(c *gin.Context) {
|
func (h *WebHandler) Index(c *gin.Context) {
|
||||||
// Redirect to configs page - configurator is accessed via /configurator?uuid=...
|
// Redirect to projects page - configurator is accessed via /configurator?uuid=...
|
||||||
c.Redirect(302, "/configs")
|
c.Redirect(302, "/projects")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *WebHandler) Configurator(c *gin.Context) {
|
func (h *WebHandler) Configurator(c *gin.Context) {
|
||||||
@@ -197,6 +197,13 @@ func (h *WebHandler) ProjectDetail(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *WebHandler) ConfigRevisions(c *gin.Context) {
|
||||||
|
h.render(c, "config_revisions.html", gin.H{
|
||||||
|
"ActivePage": "configs",
|
||||||
|
"ConfigUUID": c.Param("uuid"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *WebHandler) Pricelists(c *gin.Context) {
|
func (h *WebHandler) Pricelists(c *gin.Context) {
|
||||||
h.render(c, "pricelists.html", gin.H{"ActivePage": "pricelists"})
|
h.render(c, "pricelists.html", gin.H{"ActivePage": "pricelists"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,6 +242,31 @@ func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
|||||||
return &component, nil
|
return &component, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLocalComponentCategoriesByLotNames returns category for each lot_name in the local component cache.
|
||||||
|
// Missing lots are not included in the map; caller is responsible for strict validation.
|
||||||
|
func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[string]string, error) {
|
||||||
|
result := make(map[string]string, len(lotNames))
|
||||||
|
if len(lotNames) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
LotName string `gorm:"column:lot_name"`
|
||||||
|
Category string `gorm:"column:category"`
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
if err := l.db.Model(&LocalComponent{}).
|
||||||
|
Select("lot_name, category").
|
||||||
|
Where("lot_name IN ?", lotNames).
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
result[r.LotName] = r.Category
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetLocalComponentCategories returns distinct categories from local components
|
// GetLocalComponentCategories returns distinct categories from local components
|
||||||
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
|
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
|
||||||
var categories []string
|
var categories []string
|
||||||
@@ -302,4 +327,3 @@ func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
|
|||||||
}
|
}
|
||||||
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
|
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
|||||||
Notes: cfg.Notes,
|
Notes: cfg.Notes,
|
||||||
IsTemplate: cfg.IsTemplate,
|
IsTemplate: cfg.IsTemplate,
|
||||||
ServerCount: cfg.ServerCount,
|
ServerCount: cfg.ServerCount,
|
||||||
|
ServerModel: cfg.ServerModel,
|
||||||
|
SupportCode: cfg.SupportCode,
|
||||||
|
Article: cfg.Article,
|
||||||
PricelistID: cfg.PricelistID,
|
PricelistID: cfg.PricelistID,
|
||||||
OnlyInStock: cfg.OnlyInStock,
|
OnlyInStock: cfg.OnlyInStock,
|
||||||
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||||
@@ -72,6 +75,9 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
|||||||
Notes: local.Notes,
|
Notes: local.Notes,
|
||||||
IsTemplate: local.IsTemplate,
|
IsTemplate: local.IsTemplate,
|
||||||
ServerCount: local.ServerCount,
|
ServerCount: local.ServerCount,
|
||||||
|
ServerModel: local.ServerModel,
|
||||||
|
SupportCode: local.SupportCode,
|
||||||
|
Article: local.Article,
|
||||||
PricelistID: local.PricelistID,
|
PricelistID: local.PricelistID,
|
||||||
OnlyInStock: local.OnlyInStock,
|
OnlyInStock: local.OnlyInStock,
|
||||||
PriceUpdatedAt: local.PriceUpdatedAt,
|
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||||
@@ -85,6 +91,9 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
|||||||
userID := local.OriginalUserID
|
userID := local.OriginalUserID
|
||||||
cfg.UserID = &userID
|
cfg.UserID = &userID
|
||||||
}
|
}
|
||||||
|
if local.CurrentVersion != nil {
|
||||||
|
cfg.CurrentVersionNo = local.CurrentVersion.VersionNo
|
||||||
|
}
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
@@ -100,6 +109,8 @@ func ProjectToLocal(project *models.Project) *LocalProject {
|
|||||||
local := &LocalProject{
|
local := &LocalProject{
|
||||||
UUID: project.UUID,
|
UUID: project.UUID,
|
||||||
OwnerUsername: project.OwnerUsername,
|
OwnerUsername: project.OwnerUsername,
|
||||||
|
Code: project.Code,
|
||||||
|
Variant: project.Variant,
|
||||||
Name: project.Name,
|
Name: project.Name,
|
||||||
TrackerURL: project.TrackerURL,
|
TrackerURL: project.TrackerURL,
|
||||||
IsActive: project.IsActive,
|
IsActive: project.IsActive,
|
||||||
@@ -119,6 +130,8 @@ func LocalToProject(local *LocalProject) *models.Project {
|
|||||||
project := &models.Project{
|
project := &models.Project{
|
||||||
UUID: local.UUID,
|
UUID: local.UUID,
|
||||||
OwnerUsername: local.OwnerUsername,
|
OwnerUsername: local.OwnerUsername,
|
||||||
|
Code: local.Code,
|
||||||
|
Variant: local.Variant,
|
||||||
Name: local.Name,
|
Name: local.Name,
|
||||||
TrackerURL: local.TrackerURL,
|
TrackerURL: local.TrackerURL,
|
||||||
IsActive: local.IsActive,
|
IsActive: local.IsActive,
|
||||||
@@ -169,6 +182,7 @@ func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *Lo
|
|||||||
return &LocalPricelistItem{
|
return &LocalPricelistItem{
|
||||||
PricelistID: localPricelistID,
|
PricelistID: localPricelistID,
|
||||||
LotName: item.LotName,
|
LotName: item.LotName,
|
||||||
|
LotCategory: item.LotCategory,
|
||||||
Price: item.Price,
|
Price: item.Price,
|
||||||
AvailableQty: item.AvailableQty,
|
AvailableQty: item.AvailableQty,
|
||||||
Partnumbers: partnumbers,
|
Partnumbers: partnumbers,
|
||||||
@@ -183,6 +197,7 @@ func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *mo
|
|||||||
ID: local.ID,
|
ID: local.ID,
|
||||||
PricelistID: serverPricelistID,
|
PricelistID: serverPricelistID,
|
||||||
LotName: local.LotName,
|
LotName: local.LotName,
|
||||||
|
LotCategory: local.LotCategory,
|
||||||
Price: local.Price,
|
Price: local.Price,
|
||||||
AvailableQty: local.AvailableQty,
|
AvailableQty: local.AvailableQty,
|
||||||
Partnumbers: partnumbers,
|
Partnumbers: partnumbers,
|
||||||
|
|||||||
34
internal/localdb/converters_test.go
Normal file
34
internal/localdb/converters_test.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package localdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPricelistItemToLocal_PreservesLotCategory(t *testing.T) {
|
||||||
|
item := &models.PricelistItem{
|
||||||
|
LotName: "CPU_A",
|
||||||
|
LotCategory: "CPU",
|
||||||
|
Price: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
local := PricelistItemToLocal(item, 123)
|
||||||
|
if local.LotCategory != "CPU" {
|
||||||
|
t.Fatalf("expected LotCategory=CPU, got %q", local.LotCategory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalToPricelistItem_PreservesLotCategory(t *testing.T) {
|
||||||
|
local := &LocalPricelistItem{
|
||||||
|
LotName: "CPU_A",
|
||||||
|
LotCategory: "CPU",
|
||||||
|
Price: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
item := LocalToPricelistItem(local, 456)
|
||||||
|
if item.LotCategory != "CPU" {
|
||||||
|
t.Fatalf("expected LotCategory=CPU, got %q", item.LotCategory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
||||||
@@ -125,3 +127,129 @@ func TestRunLocalMigrationsFixesPricelistVersionUniqueIndex(t *testing.T) {
|
|||||||
t.Fatalf("expected 2 pricelists, got %d", count)
|
t.Fatalf("expected 2 pricelists, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunLocalMigrationsDeduplicatesConfigurationVersionsBySpecAndPrice(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "versions_dedup.db")
|
||||||
|
|
||||||
|
local, err := New(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open localdb: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
cfg := &LocalConfiguration{
|
||||||
|
UUID: "dedup-cfg",
|
||||||
|
Name: "Dedup",
|
||||||
|
Items: LocalConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
||||||
|
ServerCount: 1,
|
||||||
|
SyncStatus: "pending",
|
||||||
|
OriginalUsername: "tester",
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
if err := local.SaveConfiguration(cfg); err != nil {
|
||||||
|
t.Fatalf("save seed config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseV1Data, err := BuildConfigurationSnapshot(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build v1 snapshot: %v", err)
|
||||||
|
}
|
||||||
|
baseV1 := LocalConfigurationVersion{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
ConfigurationUUID: cfg.UUID,
|
||||||
|
VersionNo: 1,
|
||||||
|
Data: baseV1Data,
|
||||||
|
AppVersion: "test",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := local.DB().Create(&baseV1).Error; err != nil {
|
||||||
|
t.Fatalf("insert base v1: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.DB().Model(&LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", cfg.UUID).
|
||||||
|
Update("current_version_id", baseV1.ID).Error; err != nil {
|
||||||
|
t.Fatalf("set current_version_id to v1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v2 := LocalConfigurationVersion{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
ConfigurationUUID: cfg.UUID,
|
||||||
|
VersionNo: 2,
|
||||||
|
Data: baseV1.Data,
|
||||||
|
AppVersion: "test",
|
||||||
|
CreatedAt: time.Now().Add(1 * time.Second),
|
||||||
|
}
|
||||||
|
if err := local.DB().Create(&v2).Error; err != nil {
|
||||||
|
t.Fatalf("insert duplicate v2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modified := *cfg
|
||||||
|
modified.Items = LocalConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}}
|
||||||
|
total := modified.Items.Total()
|
||||||
|
modified.TotalPrice = &total
|
||||||
|
modified.UpdatedAt = time.Now()
|
||||||
|
v3Data, err := BuildConfigurationSnapshot(&modified)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build v3 snapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v3 := LocalConfigurationVersion{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
ConfigurationUUID: cfg.UUID,
|
||||||
|
VersionNo: 3,
|
||||||
|
Data: v3Data,
|
||||||
|
AppVersion: "test",
|
||||||
|
CreatedAt: time.Now().Add(2 * time.Second),
|
||||||
|
}
|
||||||
|
if err := local.DB().Create(&v3).Error; err != nil {
|
||||||
|
t.Fatalf("insert v3: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v4 := LocalConfigurationVersion{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
ConfigurationUUID: cfg.UUID,
|
||||||
|
VersionNo: 4,
|
||||||
|
Data: v3Data,
|
||||||
|
AppVersion: "test",
|
||||||
|
CreatedAt: time.Now().Add(3 * time.Second),
|
||||||
|
}
|
||||||
|
if err := local.DB().Create(&v4).Error; err != nil {
|
||||||
|
t.Fatalf("insert duplicate v4: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.DB().Model(&LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", cfg.UUID).
|
||||||
|
Update("current_version_id", v4.ID).Error; err != nil {
|
||||||
|
t.Fatalf("point current_version_id to duplicate v4: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.DB().Where("id = ?", "2026_02_19_configuration_versions_dedup_spec_price").
|
||||||
|
Delete(&LocalSchemaMigration{}).Error; err != nil {
|
||||||
|
t.Fatalf("delete dedup migration record: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runLocalMigrations(local.DB()); err != nil {
|
||||||
|
t.Fatalf("rerun local migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var versions []LocalConfigurationVersion
|
||||||
|
if err := local.DB().Where("configuration_uuid = ?", cfg.UUID).
|
||||||
|
Order("version_no ASC").
|
||||||
|
Find(&versions).Error; err != nil {
|
||||||
|
t.Fatalf("load versions after dedup: %v", err)
|
||||||
|
}
|
||||||
|
if len(versions) != 2 {
|
||||||
|
t.Fatalf("expected 2 versions after dedup, got %d", len(versions))
|
||||||
|
}
|
||||||
|
if versions[0].VersionNo != 1 || versions[1].VersionNo != 3 {
|
||||||
|
t.Fatalf("expected kept version numbers [1,3], got [%d,%d]", versions[0].VersionNo, versions[1].VersionNo)
|
||||||
|
}
|
||||||
|
|
||||||
|
var after LocalConfiguration
|
||||||
|
if err := local.DB().Where("uuid = ?", cfg.UUID).First(&after).Error; err != nil {
|
||||||
|
t.Fatalf("load config after dedup: %v", err)
|
||||||
|
}
|
||||||
|
if after.CurrentVersionID == nil || *after.CurrentVersionID != v3.ID {
|
||||||
|
t.Fatalf("expected current_version_id to point to kept latest version v3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||||
uuidpkg "github.com/google/uuid"
|
uuidpkg "github.com/google/uuid"
|
||||||
@@ -41,6 +42,49 @@ type LocalDB struct {
|
|||||||
path string
|
path string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetData clears local data tables while keeping connection settings.
|
||||||
|
// It does not drop schema or connection_settings.
|
||||||
|
func ResetData(dbPath string) error {
|
||||||
|
if strings.TrimSpace(dbPath) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dbPath); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("stat local db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("opening sqlite database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order does not matter because we use DELETEs without FK constraints in SQLite.
|
||||||
|
tables := []string{
|
||||||
|
"local_projects",
|
||||||
|
"local_configurations",
|
||||||
|
"local_configuration_versions",
|
||||||
|
"local_pricelists",
|
||||||
|
"local_pricelist_items",
|
||||||
|
"local_components",
|
||||||
|
"local_remote_migrations_applied",
|
||||||
|
"local_sync_guard_state",
|
||||||
|
"pending_changes",
|
||||||
|
"app_settings",
|
||||||
|
}
|
||||||
|
for _, table := range tables {
|
||||||
|
if err := db.Exec("DELETE FROM " + table).Error; err != nil {
|
||||||
|
return fmt.Errorf("clear %s: %w", table, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("local database data reset", "path", dbPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// New creates a new LocalDB instance
|
// New creates a new LocalDB instance
|
||||||
func New(dbPath string) (*LocalDB, error) {
|
func New(dbPath string) (*LocalDB, error) {
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
@@ -49,6 +93,14 @@ func New(dbPath string) (*LocalDB, error) {
|
|||||||
return nil, fmt.Errorf("creating data directory: %w", err)
|
return nil, fmt.Errorf("creating data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfgPath, err := appstate.ResolveConfigPathNearDB("", dbPath); err == nil {
|
||||||
|
if _, err := appstate.EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("backup local data: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("resolve config path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
})
|
})
|
||||||
@@ -56,10 +108,31 @@ func New(dbPath string) (*LocalDB, error) {
|
|||||||
return nil, fmt.Errorf("opening sqlite database: %w", err)
|
return nil, fmt.Errorf("opening sqlite database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ensureLocalProjectsTable(db); err != nil {
|
||||||
|
return nil, fmt.Errorf("ensure local_projects table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preflight: ensure local_projects has non-null UUIDs before AutoMigrate rebuilds tables.
|
||||||
|
if db.Migrator().HasTable(&LocalProject{}) {
|
||||||
|
if !db.Migrator().HasColumn(&LocalProject{}, "uuid") {
|
||||||
|
if err := db.Exec(`ALTER TABLE local_projects ADD COLUMN uuid TEXT`).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("adding local_projects.uuid: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var ids []uint
|
||||||
|
if err := db.Raw(`SELECT id FROM local_projects WHERE uuid IS NULL OR uuid = ''`).Scan(&ids).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("finding local_projects without uuid: %w", err)
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if err := db.Exec(`UPDATE local_projects SET uuid = ? WHERE id = ?`, uuidpkg.New().String(), id).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("backfilling local_projects.uuid: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-migrate all local tables
|
// Auto-migrate all local tables
|
||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&ConnectionSettings{},
|
&ConnectionSettings{},
|
||||||
&LocalProject{},
|
|
||||||
&LocalConfiguration{},
|
&LocalConfiguration{},
|
||||||
&LocalConfigurationVersion{},
|
&LocalConfigurationVersion{},
|
||||||
&LocalPricelist{},
|
&LocalPricelist{},
|
||||||
@@ -84,6 +157,38 @@ func New(dbPath string) (*LocalDB, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureLocalProjectsTable(db *gorm.DB) error {
|
||||||
|
if db.Migrator().HasTable(&LocalProject{}) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Exec(`
|
||||||
|
CREATE TABLE local_projects (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
|
server_id INTEGER NULL,
|
||||||
|
owner_username TEXT NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
variant TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NULL,
|
||||||
|
tracker_url TEXT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_system INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME,
|
||||||
|
synced_at DATETIME NULL,
|
||||||
|
sync_status TEXT DEFAULT 'local'
|
||||||
|
)`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_owner_username ON local_projects(owner_username)`).Error
|
||||||
|
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_active ON local_projects(is_active)`).Error
|
||||||
|
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_system ON local_projects(is_system)`).Error
|
||||||
|
_ = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// HasSettings returns true if connection settings exist
|
// HasSettings returns true if connection settings exist
|
||||||
func (l *LocalDB) HasSettings() bool {
|
func (l *LocalDB) HasSettings() bool {
|
||||||
var count int64
|
var count int64
|
||||||
@@ -258,7 +363,8 @@ func (l *LocalDB) EnsureDefaultProject(ownerUsername string) (*LocalProject, err
|
|||||||
project = &LocalProject{
|
project = &LocalProject{
|
||||||
UUID: uuidpkg.NewString(),
|
UUID: uuidpkg.NewString(),
|
||||||
OwnerUsername: "",
|
OwnerUsername: "",
|
||||||
Name: "Без проекта",
|
Code: "Без проекта",
|
||||||
|
Name: ptrString("Без проекта"),
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsSystem: true,
|
IsSystem: true,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
@@ -286,7 +392,8 @@ func (l *LocalDB) ConsolidateSystemProjects() (int64, error) {
|
|||||||
canonical = LocalProject{
|
canonical = LocalProject{
|
||||||
UUID: uuidpkg.NewString(),
|
UUID: uuidpkg.NewString(),
|
||||||
OwnerUsername: "",
|
OwnerUsername: "",
|
||||||
Name: "Без проекта",
|
Code: "Без проекта",
|
||||||
|
Name: ptrString("Без проекта"),
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsSystem: true,
|
IsSystem: true,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
@@ -367,6 +474,10 @@ WHERE (
|
|||||||
return tx.RowsAffected, tx.Error
|
return tx.RowsAffected, tx.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ptrString(value string) *string {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
// BackfillConfigurationProjects ensures every configuration has project_uuid set.
|
// BackfillConfigurationProjects ensures every configuration has project_uuid set.
|
||||||
// If missing, it assigns system project "Без проекта" for configuration owner.
|
// If missing, it assigns system project "Без проекта" for configuration owner.
|
||||||
func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
|
func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
|
||||||
@@ -425,18 +536,36 @@ func (l *LocalDB) ListConfigurationsWithFilters(status string, search string, of
|
|||||||
query := l.db.Model(&LocalConfiguration{})
|
query := l.db.Model(&LocalConfiguration{})
|
||||||
switch status {
|
switch status {
|
||||||
case "active":
|
case "active":
|
||||||
query = query.Where("is_active = ?", true)
|
query = query.Where("local_configurations.is_active = ?", true)
|
||||||
case "archived":
|
case "archived":
|
||||||
query = query.Where("is_active = ?", false)
|
query = query.Where("local_configurations.is_active = ?", false)
|
||||||
case "all", "":
|
case "all", "":
|
||||||
// no-op
|
// no-op
|
||||||
default:
|
default:
|
||||||
query = query.Where("is_active = ?", true)
|
query = query.Where("local_configurations.is_active = ?", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
search = strings.TrimSpace(search)
|
search = strings.TrimSpace(search)
|
||||||
if search != "" {
|
if search != "" {
|
||||||
query = query.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(search)+"%")
|
needle := "%" + strings.ToLower(search) + "%"
|
||||||
|
hasProjectsTable := l.db.Migrator().HasTable(&LocalProject{})
|
||||||
|
hasServerModel := l.db.Migrator().HasColumn(&LocalConfiguration{}, "server_model")
|
||||||
|
|
||||||
|
conditions := []string{"LOWER(local_configurations.name) LIKE ?"}
|
||||||
|
args := []interface{}{needle}
|
||||||
|
|
||||||
|
if hasProjectsTable {
|
||||||
|
query = query.Joins("LEFT JOIN local_projects lp ON lp.uuid = local_configurations.project_uuid")
|
||||||
|
conditions = append(conditions, "LOWER(COALESCE(lp.name, '')) LIKE ?")
|
||||||
|
args = append(args, needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasServerModel {
|
||||||
|
conditions = append(conditions, "LOWER(COALESCE(local_configurations.server_model, '')) LIKE ?")
|
||||||
|
args = append(args, needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
query = query.Where(strings.Join(conditions, " OR "), args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
@@ -445,7 +574,7 @@ func (l *LocalDB) ListConfigurationsWithFilters(status string, search string, of
|
|||||||
}
|
}
|
||||||
|
|
||||||
var configs []LocalConfiguration
|
var configs []LocalConfiguration
|
||||||
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil {
|
if err := query.Order("local_configurations.created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
return configs, total, nil
|
return configs, total, nil
|
||||||
@@ -645,6 +774,17 @@ func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 {
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountLocalPricelistItemsWithEmptyCategory returns the number of items for a pricelist with missing lot_category.
|
||||||
|
func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
if err := l.db.Model(&LocalPricelistItem{}).
|
||||||
|
Where("pricelist_id = ? AND (lot_category IS NULL OR TRIM(lot_category) = '')", pricelistID).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SaveLocalPricelistItems saves pricelist items to local SQLite
|
// SaveLocalPricelistItems saves pricelist items to local SQLite
|
||||||
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
|
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
@@ -665,6 +805,30 @@ func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReplaceLocalPricelistItems atomically replaces all items for a pricelist.
|
||||||
|
func (l *LocalDB) ReplaceLocalPricelistItems(pricelistID uint, items []LocalPricelistItem) error {
|
||||||
|
return l.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("pricelist_id = ?", pricelistID).Delete(&LocalPricelistItem{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
batchSize := 500
|
||||||
|
for i := 0; i < len(items); i += batchSize {
|
||||||
|
end := i + batchSize
|
||||||
|
if end > len(items) {
|
||||||
|
end = len(items)
|
||||||
|
}
|
||||||
|
if err := tx.CreateInBatches(items[i:end], batchSize).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GetLocalPricelistItems returns items for a local pricelist
|
// GetLocalPricelistItems returns items for a local pricelist
|
||||||
func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem, error) {
|
func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem, error) {
|
||||||
var items []LocalPricelistItem
|
var items []LocalPricelistItem
|
||||||
@@ -684,6 +848,36 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64
|
|||||||
return item.Price, nil
|
return item.Price, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID.
|
||||||
|
// Missing lots are not included in the map; caller is responsible for strict validation.
|
||||||
|
func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
||||||
|
result := make(map[string]string, len(lotNames))
|
||||||
|
if serverPricelistID == 0 || len(lotNames) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
localPL, err := l.GetLocalPricelistByServerID(serverPricelistID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
LotName string `gorm:"column:lot_name"`
|
||||||
|
LotCategory string `gorm:"column:lot_category"`
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
if err := l.db.Model(&LocalPricelistItem{}).
|
||||||
|
Select("lot_name, lot_category").
|
||||||
|
Where("pricelist_id = ? AND lot_name IN ?", localPL.ID, lotNames).
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
result[r.LotName] = r.LotCategory
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// MarkPricelistAsUsed marks a pricelist as used by a configuration
|
// MarkPricelistAsUsed marks a pricelist as used by a configuration
|
||||||
func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
|
func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
|
||||||
return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID).
|
return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID).
|
||||||
@@ -847,6 +1041,145 @@ func (l *LocalDB) GetPendingCount() int64 {
|
|||||||
return l.CountPendingChanges()
|
return l.CountPendingChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RepairPendingChanges attempts to fix errored pending changes by validating and correcting data.
|
||||||
|
// Returns the number of changes repaired and a list of errors that couldn't be fixed.
|
||||||
|
func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
|
||||||
|
var erroredChanges []PendingChange
|
||||||
|
if err := l.db.Where("last_error != ?", "").Find(&erroredChanges).Error; err != nil {
|
||||||
|
return 0, nil, fmt.Errorf("fetching errored changes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(erroredChanges) == 0 {
|
||||||
|
return 0, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
repaired := 0
|
||||||
|
var remainingErrors []string
|
||||||
|
|
||||||
|
for _, change := range erroredChanges {
|
||||||
|
var repairErr error
|
||||||
|
switch change.EntityType {
|
||||||
|
case "project":
|
||||||
|
repairErr = l.repairProjectChange(&change)
|
||||||
|
case "configuration":
|
||||||
|
repairErr = l.repairConfigurationChange(&change)
|
||||||
|
default:
|
||||||
|
repairErr = fmt.Errorf("unknown entity type: %s", change.EntityType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if repairErr != nil {
|
||||||
|
remainingErrors = append(remainingErrors, fmt.Sprintf("%s %s %s: %v",
|
||||||
|
change.Operation, change.EntityType, change.EntityUUID[:8], repairErr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear error and reset attempts
|
||||||
|
if err := l.db.Model(&PendingChange{}).Where("id = ?", change.ID).Updates(map[string]interface{}{
|
||||||
|
"last_error": "",
|
||||||
|
"attempts": 0,
|
||||||
|
}).Error; err != nil {
|
||||||
|
remainingErrors = append(remainingErrors, fmt.Sprintf("clearing error for %s: %v", change.EntityUUID[:8], err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
repaired++
|
||||||
|
}
|
||||||
|
|
||||||
|
return repaired, remainingErrors, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// repairProjectChange validates and fixes project data.
|
||||||
|
// Note: This only validates local data. Server-side conflicts (like duplicate code+variant)
|
||||||
|
// are handled by sync service layer with deduplication logic.
|
||||||
|
func (l *LocalDB) repairProjectChange(change *PendingChange) error {
|
||||||
|
project, err := l.GetProjectByUUID(change.EntityUUID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("project not found locally: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modified := false
|
||||||
|
|
||||||
|
// Fix Code: must be non-empty
|
||||||
|
if strings.TrimSpace(project.Code) == "" {
|
||||||
|
if project.Name != nil && strings.TrimSpace(*project.Name) != "" {
|
||||||
|
project.Code = strings.TrimSpace(*project.Name)
|
||||||
|
} else {
|
||||||
|
project.Code = project.UUID[:8]
|
||||||
|
}
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix Name: use Code if empty
|
||||||
|
if project.Name == nil || strings.TrimSpace(*project.Name) == "" {
|
||||||
|
name := project.Code
|
||||||
|
project.Name = &name
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix OwnerUsername: must be non-empty
|
||||||
|
if strings.TrimSpace(project.OwnerUsername) == "" {
|
||||||
|
project.OwnerUsername = l.GetDBUser()
|
||||||
|
if project.OwnerUsername == "" {
|
||||||
|
return fmt.Errorf("cannot determine owner username")
|
||||||
|
}
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for local duplicates with same (code, variant)
|
||||||
|
var duplicate LocalProject
|
||||||
|
err = l.db.Where("code = ? AND variant = ? AND uuid != ?", project.Code, project.Variant, project.UUID).
|
||||||
|
First(&duplicate).Error
|
||||||
|
if err == nil {
|
||||||
|
// Found local duplicate - deduplicate by appending UUID suffix to variant
|
||||||
|
if project.Variant == "" {
|
||||||
|
project.Variant = project.UUID[:8]
|
||||||
|
} else {
|
||||||
|
project.Variant = project.Variant + "-" + project.UUID[:8]
|
||||||
|
}
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if modified {
|
||||||
|
if err := l.SaveProject(project); err != nil {
|
||||||
|
return fmt.Errorf("saving repaired project: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// repairConfigurationChange validates and fixes configuration data
|
||||||
|
func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
|
||||||
|
config, err := l.GetConfigurationByUUID(change.EntityUUID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("configuration not found locally: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modified := false
|
||||||
|
|
||||||
|
// Check if referenced project exists
|
||||||
|
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
|
||||||
|
_, err := l.GetProjectByUUID(*config.ProjectUUID)
|
||||||
|
if err != nil {
|
||||||
|
// Project doesn't exist locally - use default system project
|
||||||
|
systemProject, sysErr := l.EnsureDefaultProject(config.OriginalUsername)
|
||||||
|
if sysErr != nil {
|
||||||
|
return fmt.Errorf("getting system project: %w", sysErr)
|
||||||
|
}
|
||||||
|
config.ProjectUUID = &systemProject.UUID
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if modified {
|
||||||
|
if err := l.SaveConfiguration(config); err != nil {
|
||||||
|
return fmt.Errorf("saving repaired configuration: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetRemoteMigrationApplied returns a locally applied remote migration by ID.
|
// GetRemoteMigrationApplied returns a locally applied remote migration by ID.
|
||||||
func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) {
|
func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) {
|
||||||
var migration LocalRemoteMigrationApplied
|
var migration LocalRemoteMigrationApplied
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ func TestRunLocalMigrationsBackfillsDefaultProject(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get system project: %v", err)
|
t.Fatalf("get system project: %v", err)
|
||||||
}
|
}
|
||||||
if project.Name != "Без проекта" {
|
if project.Name == nil || *project.Name != "Без проекта" {
|
||||||
t.Fatalf("expected system project name, got %q", project.Name)
|
t.Fatalf("expected system project name, got %v", project.Name)
|
||||||
}
|
}
|
||||||
if !project.IsSystem {
|
if !project.IsSystem {
|
||||||
t.Fatalf("expected system project flag")
|
t.Fatalf("expected system project flag")
|
||||||
|
|||||||
@@ -68,6 +68,46 @@ var localMigrations = []localMigration{
|
|||||||
name: "Add warehouse_pricelist_id and competitor_pricelist_id to local_configurations",
|
name: "Add warehouse_pricelist_id and competitor_pricelist_id to local_configurations",
|
||||||
run: addWarehouseCompetitorPriceLists,
|
run: addWarehouseCompetitorPriceLists,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_11_local_pricelist_item_category",
|
||||||
|
name: "Add lot_category to local_pricelist_items and create indexes",
|
||||||
|
run: addLocalPricelistItemCategoryAndIndexes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_11_local_config_article",
|
||||||
|
name: "Add article to local_configurations",
|
||||||
|
run: addLocalConfigurationArticle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_11_local_config_server_model",
|
||||||
|
name: "Add server_model to local_configurations",
|
||||||
|
run: addLocalConfigurationServerModel,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_11_local_config_support_code",
|
||||||
|
name: "Add support_code to local_configurations",
|
||||||
|
run: addLocalConfigurationSupportCode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_13_local_project_code",
|
||||||
|
name: "Add project code to local_projects and backfill",
|
||||||
|
run: addLocalProjectCode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_13_local_project_variant",
|
||||||
|
name: "Add project variant to local_projects and backfill",
|
||||||
|
run: addLocalProjectVariant,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_13_local_project_name_nullable",
|
||||||
|
name: "Allow NULL project names in local_projects",
|
||||||
|
run: allowLocalProjectNameNull,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_19_configuration_versions_dedup_spec_price",
|
||||||
|
name: "Deduplicate configuration revisions by spec+price",
|
||||||
|
run: deduplicateConfigurationVersionsBySpecAndPrice,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func runLocalMigrations(db *gorm.DB) error {
|
func runLocalMigrations(db *gorm.DB) error {
|
||||||
@@ -204,7 +244,8 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
|
|||||||
project = LocalProject{
|
project = LocalProject{
|
||||||
UUID: uuid.NewString(),
|
UUID: uuid.NewString(),
|
||||||
OwnerUsername: ownerUsername,
|
OwnerUsername: ownerUsername,
|
||||||
Name: "Без проекта",
|
Code: "Без проекта",
|
||||||
|
Name: ptrString("Без проекта"),
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsSystem: true,
|
IsSystem: true,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
@@ -218,6 +259,139 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
|
|||||||
return &project, nil
|
return &project, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addLocalProjectCode(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN code TEXT`).Error; err != nil {
|
||||||
|
if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
|
||||||
|
!strings.Contains(strings.ToLower(err.Error()), "exists") {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop unique index if it already exists to allow de-duplication updates.
|
||||||
|
if err := tx.Exec(`DROP INDEX IF EXISTS idx_local_projects_code`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy code from current project name.
|
||||||
|
if err := tx.Exec(`
|
||||||
|
UPDATE local_projects
|
||||||
|
SET code = TRIM(COALESCE(name, ''))`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure any remaining blanks have a unique fallback.
|
||||||
|
if err := tx.Exec(`
|
||||||
|
UPDATE local_projects
|
||||||
|
SET code = 'P-' || uuid
|
||||||
|
WHERE code IS NULL OR TRIM(code) = ''`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// De-duplicate codes: OPS-1948-2, OPS-1948-3...
|
||||||
|
if err := tx.Exec(`
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT id, code,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY code ORDER BY id) AS rn
|
||||||
|
FROM local_projects
|
||||||
|
)
|
||||||
|
UPDATE local_projects
|
||||||
|
SET code = code || '-' || (SELECT rn FROM ranked WHERE ranked.id = local_projects.id)
|
||||||
|
WHERE id IN (SELECT id FROM ranked WHERE rn > 1)`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create unique index for project codes (ignore if exists).
|
||||||
|
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code ON local_projects(code)`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addLocalProjectVariant(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN variant TEXT NOT NULL DEFAULT ''`).Error; err != nil {
|
||||||
|
if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
|
||||||
|
!strings.Contains(strings.ToLower(err.Error()), "exists") {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop legacy code index if present.
|
||||||
|
if err := tx.Exec(`DROP INDEX IF EXISTS idx_local_projects_code`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset code from name and clear variant.
|
||||||
|
if err := tx.Exec(`
|
||||||
|
UPDATE local_projects
|
||||||
|
SET code = TRIM(COALESCE(name, '')),
|
||||||
|
variant = ''`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// De-duplicate by assigning variant numbers: 2,3...
|
||||||
|
if err := tx.Exec(`
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT id, code,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY code ORDER BY id) AS rn
|
||||||
|
FROM local_projects
|
||||||
|
)
|
||||||
|
UPDATE local_projects
|
||||||
|
SET variant = CASE
|
||||||
|
WHEN (SELECT rn FROM ranked WHERE ranked.id = local_projects.id) = 1 THEN ''
|
||||||
|
ELSE '-' || CAST((SELECT rn FROM ranked WHERE ranked.id = local_projects.id) AS TEXT)
|
||||||
|
END`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowLocalProjectNameNull(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec(`ALTER TABLE local_projects RENAME TO local_projects_old`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
CREATE TABLE local_projects (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
|
server_id INTEGER NULL,
|
||||||
|
owner_username TEXT NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
variant TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NULL,
|
||||||
|
tracker_url TEXT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_system INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME,
|
||||||
|
synced_at DATETIME NULL,
|
||||||
|
sync_status TEXT DEFAULT 'local'
|
||||||
|
)`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_owner_username ON local_projects(owner_username)`).Error
|
||||||
|
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_active ON local_projects(is_active)`).Error
|
||||||
|
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_system ON local_projects(is_system)`).Error
|
||||||
|
_ = tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
INSERT INTO local_projects (id, uuid, server_id, owner_username, code, variant, name, tracker_url, is_active, is_system, created_at, updated_at, synced_at, sync_status)
|
||||||
|
SELECT id, uuid, server_id, owner_username, code, variant, name, tracker_url, is_active, is_system, created_at, updated_at, synced_at, sync_status
|
||||||
|
FROM local_projects_old`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = tx.Exec(`DROP TABLE local_projects_old`).Error
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func backfillConfigurationPricelists(tx *gorm.DB) error {
|
func backfillConfigurationPricelists(tx *gorm.DB) error {
|
||||||
var latest LocalPricelist
|
var latest LocalPricelist
|
||||||
if err := tx.Where("source = ?", "estimate").Order("created_at DESC").First(&latest).Error; err != nil {
|
if err := tx.Where("source = ?", "estimate").Order("created_at DESC").First(&latest).Error; err != nil {
|
||||||
@@ -259,6 +433,93 @@ func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
|
|||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deduplicateConfigurationVersionsBySpecAndPrice(tx *gorm.DB) error {
|
||||||
|
var configs []LocalConfiguration
|
||||||
|
if err := tx.Select("uuid", "current_version_id").Find(&configs).Error; err != nil {
|
||||||
|
return fmt.Errorf("load configurations for revision deduplication: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var removedTotal int
|
||||||
|
for i := range configs {
|
||||||
|
cfg := configs[i]
|
||||||
|
|
||||||
|
var versions []LocalConfigurationVersion
|
||||||
|
if err := tx.Where("configuration_uuid = ?", cfg.UUID).
|
||||||
|
Order("version_no ASC, created_at ASC").
|
||||||
|
Find(&versions).Error; err != nil {
|
||||||
|
return fmt.Errorf("load versions for %s: %w", cfg.UUID, err)
|
||||||
|
}
|
||||||
|
if len(versions) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteIDs := make([]string, 0)
|
||||||
|
deleteSet := make(map[string]struct{})
|
||||||
|
kept := make([]LocalConfigurationVersion, 0, len(versions))
|
||||||
|
var prevKey string
|
||||||
|
hasPrev := false
|
||||||
|
|
||||||
|
for _, version := range versions {
|
||||||
|
snapshotCfg, err := DecodeConfigurationSnapshot(version.Data)
|
||||||
|
if err != nil {
|
||||||
|
// Keep malformed snapshots untouched and reset chain to avoid accidental removals.
|
||||||
|
kept = append(kept, version)
|
||||||
|
hasPrev = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := BuildConfigurationSpecPriceFingerprint(snapshotCfg)
|
||||||
|
if err != nil {
|
||||||
|
kept = append(kept, version)
|
||||||
|
hasPrev = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasPrev || key != prevKey {
|
||||||
|
kept = append(kept, version)
|
||||||
|
prevKey = key
|
||||||
|
hasPrev = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteIDs = append(deleteIDs, version.ID)
|
||||||
|
deleteSet[version.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(deleteIDs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Where("id IN ?", deleteIDs).Delete(&LocalConfigurationVersion{}).Error; err != nil {
|
||||||
|
return fmt.Errorf("delete duplicate versions for %s: %w", cfg.UUID, err)
|
||||||
|
}
|
||||||
|
removedTotal += len(deleteIDs)
|
||||||
|
|
||||||
|
latestKeptID := kept[len(kept)-1].ID
|
||||||
|
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
|
||||||
|
if err := tx.Model(&LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", cfg.UUID).
|
||||||
|
Update("current_version_id", latestKeptID).Error; err != nil {
|
||||||
|
return fmt.Errorf("set missing current_version_id for %s: %w", cfg.UUID, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, deleted := deleteSet[*cfg.CurrentVersionID]; deleted {
|
||||||
|
if err := tx.Model(&LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", cfg.UUID).
|
||||||
|
Update("current_version_id", latestKeptID).Error; err != nil {
|
||||||
|
return fmt.Errorf("repair current_version_id for %s: %w", cfg.UUID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if removedTotal > 0 {
|
||||||
|
slog.Info("deduplicated configuration revisions", "removed_versions", removedTotal)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func fixLocalPricelistIndexes(tx *gorm.DB) error {
|
func fixLocalPricelistIndexes(tx *gorm.DB) error {
|
||||||
type indexRow struct {
|
type indexRow struct {
|
||||||
Name string `gorm:"column:name"`
|
Name string `gorm:"column:name"`
|
||||||
@@ -436,3 +697,112 @@ func addWarehouseCompetitorPriceLists(tx *gorm.DB) error {
|
|||||||
slog.Info("added warehouse and competitor pricelist fields to local_configurations")
|
slog.Info("added warehouse and competitor pricelist fields to local_configurations")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addLocalPricelistItemCategoryAndIndexes(tx *gorm.DB) error {
|
||||||
|
type columnInfo struct {
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var columns []columnInfo
|
||||||
|
if err := tx.Raw(`
|
||||||
|
SELECT name FROM pragma_table_info('local_pricelist_items')
|
||||||
|
WHERE name IN ('lot_category')
|
||||||
|
`).Scan(&columns).Error; err != nil {
|
||||||
|
return fmt.Errorf("check local_pricelist_items(lot_category) existence: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(columns) == 0 {
|
||||||
|
if err := tx.Exec(`
|
||||||
|
ALTER TABLE local_pricelist_items
|
||||||
|
ADD COLUMN lot_category TEXT
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("add local_pricelist_items.lot_category: %w", err)
|
||||||
|
}
|
||||||
|
slog.Info("added lot_category to local_pricelist_items")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_local_pricelist_items_pricelist_lot
|
||||||
|
ON local_pricelist_items(pricelist_id, lot_name)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("ensure idx_local_pricelist_items_pricelist_lot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_local_pricelist_items_lot_category
|
||||||
|
ON local_pricelist_items(lot_category)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("ensure idx_local_pricelist_items_lot_category: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addLocalConfigurationArticle(tx *gorm.DB) error {
|
||||||
|
type columnInfo struct {
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
var columns []columnInfo
|
||||||
|
if err := tx.Raw(`
|
||||||
|
SELECT name FROM pragma_table_info('local_configurations')
|
||||||
|
WHERE name IN ('article')
|
||||||
|
`).Scan(&columns).Error; err != nil {
|
||||||
|
return fmt.Errorf("check local_configurations(article) existence: %w", err)
|
||||||
|
}
|
||||||
|
if len(columns) == 0 {
|
||||||
|
if err := tx.Exec(`
|
||||||
|
ALTER TABLE local_configurations
|
||||||
|
ADD COLUMN article TEXT
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("add local_configurations.article: %w", err)
|
||||||
|
}
|
||||||
|
slog.Info("added article to local_configurations")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addLocalConfigurationServerModel(tx *gorm.DB) error {
|
||||||
|
type columnInfo struct {
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
var columns []columnInfo
|
||||||
|
if err := tx.Raw(`
|
||||||
|
SELECT name FROM pragma_table_info('local_configurations')
|
||||||
|
WHERE name IN ('server_model')
|
||||||
|
`).Scan(&columns).Error; err != nil {
|
||||||
|
return fmt.Errorf("check local_configurations(server_model) existence: %w", err)
|
||||||
|
}
|
||||||
|
if len(columns) == 0 {
|
||||||
|
if err := tx.Exec(`
|
||||||
|
ALTER TABLE local_configurations
|
||||||
|
ADD COLUMN server_model TEXT
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("add local_configurations.server_model: %w", err)
|
||||||
|
}
|
||||||
|
slog.Info("added server_model to local_configurations")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addLocalConfigurationSupportCode(tx *gorm.DB) error {
|
||||||
|
type columnInfo struct {
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
var columns []columnInfo
|
||||||
|
if err := tx.Raw(`
|
||||||
|
SELECT name FROM pragma_table_info('local_configurations')
|
||||||
|
WHERE name IN ('support_code')
|
||||||
|
`).Scan(&columns).Error; err != nil {
|
||||||
|
return fmt.Errorf("check local_configurations(support_code) existence: %w", err)
|
||||||
|
}
|
||||||
|
if len(columns) == 0 {
|
||||||
|
if err := tx.Exec(`
|
||||||
|
ALTER TABLE local_configurations
|
||||||
|
ADD COLUMN support_code TEXT
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("add local_configurations.support_code: %w", err)
|
||||||
|
}
|
||||||
|
slog.Info("added support_code to local_configurations")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ type LocalConfiguration struct {
|
|||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||||
|
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
|
||||||
|
SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
|
||||||
|
Article string `gorm:"size:80" json:"article,omitempty"`
|
||||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||||
@@ -120,7 +123,9 @@ type LocalProject struct {
|
|||||||
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
||||||
ServerID *uint `json:"server_id,omitempty"`
|
ServerID *uint `json:"server_id,omitempty"`
|
||||||
OwnerUsername string `gorm:"not null;index" json:"owner_username"`
|
OwnerUsername string `gorm:"not null;index" json:"owner_username"`
|
||||||
Name string `gorm:"not null" json:"name"`
|
Code string `gorm:"not null;index:idx_local_projects_code_variant,priority:1" json:"code"`
|
||||||
|
Variant string `gorm:"default:'';index:idx_local_projects_code_variant,priority:2" json:"variant"`
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
TrackerURL string `json:"tracker_url"`
|
TrackerURL string `json:"tracker_url"`
|
||||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||||
@@ -172,6 +177,7 @@ type LocalPricelistItem struct {
|
|||||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
|
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
|
||||||
LotName string `gorm:"not null" json:"lot_name"`
|
LotName string `gorm:"not null" json:"lot_name"`
|
||||||
|
LotCategory string `gorm:"column:lot_category" json:"lot_category,omitempty"`
|
||||||
Price float64 `gorm:"not null" json:"price"`
|
Price float64 `gorm:"not null" json:"price"`
|
||||||
AvailableQty *float64 `json:"available_qty,omitempty"`
|
AvailableQty *float64 `json:"available_qty,omitempty"`
|
||||||
Partnumbers LocalStringList `gorm:"type:text" json:"partnumbers,omitempty"`
|
Partnumbers LocalStringList `gorm:"type:text" json:"partnumbers,omitempty"`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package localdb
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,6 +23,9 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
|||||||
"notes": localCfg.Notes,
|
"notes": localCfg.Notes,
|
||||||
"is_template": localCfg.IsTemplate,
|
"is_template": localCfg.IsTemplate,
|
||||||
"server_count": localCfg.ServerCount,
|
"server_count": localCfg.ServerCount,
|
||||||
|
"server_model": localCfg.ServerModel,
|
||||||
|
"support_code": localCfg.SupportCode,
|
||||||
|
"article": localCfg.Article,
|
||||||
"pricelist_id": localCfg.PricelistID,
|
"pricelist_id": localCfg.PricelistID,
|
||||||
"only_in_stock": localCfg.OnlyInStock,
|
"only_in_stock": localCfg.OnlyInStock,
|
||||||
"price_updated_at": localCfg.PriceUpdatedAt,
|
"price_updated_at": localCfg.PriceUpdatedAt,
|
||||||
@@ -52,6 +56,9 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
|||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
IsTemplate bool `json:"is_template"`
|
IsTemplate bool `json:"is_template"`
|
||||||
ServerCount int `json:"server_count"`
|
ServerCount int `json:"server_count"`
|
||||||
|
ServerModel string `json:"server_model"`
|
||||||
|
SupportCode string `json:"support_code"`
|
||||||
|
Article string `json:"article"`
|
||||||
PricelistID *uint `json:"pricelist_id"`
|
PricelistID *uint `json:"pricelist_id"`
|
||||||
OnlyInStock bool `json:"only_in_stock"`
|
OnlyInStock bool `json:"only_in_stock"`
|
||||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||||
@@ -78,6 +85,9 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
|||||||
Notes: snapshot.Notes,
|
Notes: snapshot.Notes,
|
||||||
IsTemplate: snapshot.IsTemplate,
|
IsTemplate: snapshot.IsTemplate,
|
||||||
ServerCount: snapshot.ServerCount,
|
ServerCount: snapshot.ServerCount,
|
||||||
|
ServerModel: snapshot.ServerModel,
|
||||||
|
SupportCode: snapshot.SupportCode,
|
||||||
|
Article: snapshot.Article,
|
||||||
PricelistID: snapshot.PricelistID,
|
PricelistID: snapshot.PricelistID,
|
||||||
OnlyInStock: snapshot.OnlyInStock,
|
OnlyInStock: snapshot.OnlyInStock,
|
||||||
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
||||||
@@ -85,3 +95,51 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
|||||||
OriginalUsername: snapshot.OriginalUsername,
|
OriginalUsername: snapshot.OriginalUsername,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type configurationSpecPriceFingerprint struct {
|
||||||
|
Items []configurationSpecPriceFingerprintItem `json:"items"`
|
||||||
|
ServerCount int `json:"server_count"`
|
||||||
|
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||||
|
CustomPrice *float64 `json:"custom_price,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type configurationSpecPriceFingerprintItem struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
UnitPrice float64 `json:"unit_price"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildConfigurationSpecPriceFingerprint returns a stable JSON key based on
|
||||||
|
// spec + price fields only, used for revision deduplication.
|
||||||
|
func BuildConfigurationSpecPriceFingerprint(localCfg *LocalConfiguration) (string, error) {
|
||||||
|
items := make([]configurationSpecPriceFingerprintItem, 0, len(localCfg.Items))
|
||||||
|
for _, item := range localCfg.Items {
|
||||||
|
items = append(items, configurationSpecPriceFingerprintItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
if items[i].LotName != items[j].LotName {
|
||||||
|
return items[i].LotName < items[j].LotName
|
||||||
|
}
|
||||||
|
if items[i].Quantity != items[j].Quantity {
|
||||||
|
return items[i].Quantity < items[j].Quantity
|
||||||
|
}
|
||||||
|
return items[i].UnitPrice < items[j].UnitPrice
|
||||||
|
})
|
||||||
|
|
||||||
|
payload := configurationSpecPriceFingerprint{
|
||||||
|
Items: items,
|
||||||
|
ServerCount: localCfg.ServerCount,
|
||||||
|
TotalPrice: localCfg.TotalPrice,
|
||||||
|
CustomPrice: localCfg.CustomPrice,
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal spec+price fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
return string(raw), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ type Configuration struct {
|
|||||||
Notes string `gorm:"type:text" json:"notes"`
|
Notes string `gorm:"type:text" json:"notes"`
|
||||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||||
|
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
|
||||||
|
SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
|
||||||
|
Article string `gorm:"size:80" json:"article,omitempty"`
|
||||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||||
@@ -60,6 +63,7 @@ type Configuration struct {
|
|||||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
CurrentVersionNo int `gorm:"-" json:"current_version_no,omitempty"`
|
||||||
|
|
||||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ type PricelistItem struct {
|
|||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
PricelistID uint `gorm:"not null;index:idx_pricelist_lot" json:"pricelist_id"`
|
PricelistID uint `gorm:"not null;index:idx_pricelist_lot" json:"pricelist_id"`
|
||||||
LotName string `gorm:"size:255;not null;index:idx_pricelist_lot" json:"lot_name"`
|
LotName string `gorm:"size:255;not null;index:idx_pricelist_lot" json:"lot_name"`
|
||||||
|
LotCategory string `gorm:"column:lot_category;size:50" json:"lot_category,omitempty"`
|
||||||
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
|
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
|
||||||
PriceMethod string `gorm:"size:20" json:"price_method"`
|
PriceMethod string `gorm:"size:20" json:"price_method"`
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ type Project struct {
|
|||||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||||
OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"`
|
OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"`
|
||||||
Name string `gorm:"size:200;not null" json:"name"`
|
Code string `gorm:"size:100;not null;index:idx_qt_projects_code_variant,priority:1" json:"code"`
|
||||||
|
Variant string `gorm:"size:100;not null;default:'';index:idx_qt_projects_code_variant,priority:2" json:"variant"`
|
||||||
|
Name *string `gorm:"size:200" json:"name,omitempty"`
|
||||||
TrackerURL string `gorm:"size:500" json:"tracker_url"`
|
TrackerURL string `gorm:"size:500" json:"tracker_url"`
|
||||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||||
|
|||||||
@@ -238,11 +238,7 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
|
|||||||
if err := r.db.Where("lot_name = ?", items[i].LotName).First(&lot).Error; err == nil {
|
if err := r.db.Where("lot_name = ?", items[i].LotName).First(&lot).Error; err == nil {
|
||||||
items[i].LotDescription = lot.LotDescription
|
items[i].LotDescription = lot.LotDescription
|
||||||
}
|
}
|
||||||
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
|
items[i].Category = strings.TrimSpace(items[i].LotCategory)
|
||||||
parts := strings.SplitN(items[i].LotName, "_", 2)
|
|
||||||
if len(parts) >= 1 {
|
|
||||||
items[i].Category = parts[0]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.enrichItemsWithStock(items); err != nil {
|
if err := r.enrichItemsWithStock(items); err != nil {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ func (r *ProjectRepository) UpsertByUUID(project *models.Project) error {
|
|||||||
Columns: []clause.Column{{Name: "uuid"}},
|
Columns: []clause.Column{{Name: "uuid"}},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
DoUpdates: clause.AssignmentColumns([]string{
|
||||||
"owner_username",
|
"owner_username",
|
||||||
|
"code",
|
||||||
|
"variant",
|
||||||
"name",
|
"name",
|
||||||
"tracker_url",
|
"tracker_url",
|
||||||
"is_active",
|
"is_active",
|
||||||
|
|||||||
@@ -52,10 +52,20 @@ type CreateConfigRequest struct {
|
|||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
IsTemplate bool `json:"is_template"`
|
IsTemplate bool `json:"is_template"`
|
||||||
ServerCount int `json:"server_count"`
|
ServerCount int `json:"server_count"`
|
||||||
|
ServerModel string `json:"server_model,omitempty"`
|
||||||
|
SupportCode string `json:"support_code,omitempty"`
|
||||||
|
Article string `json:"article,omitempty"`
|
||||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||||
OnlyInStock bool `json:"only_in_stock"`
|
OnlyInStock bool `json:"only_in_stock"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ArticlePreviewRequest struct {
|
||||||
|
Items models.ConfigItems `json:"items"`
|
||||||
|
ServerModel string `json:"server_model"`
|
||||||
|
SupportCode string `json:"support_code,omitempty"`
|
||||||
|
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -84,6 +94,9 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
|
|||||||
Notes: req.Notes,
|
Notes: req.Notes,
|
||||||
IsTemplate: req.IsTemplate,
|
IsTemplate: req.IsTemplate,
|
||||||
ServerCount: req.ServerCount,
|
ServerCount: req.ServerCount,
|
||||||
|
ServerModel: req.ServerModel,
|
||||||
|
SupportCode: req.SupportCode,
|
||||||
|
Article: req.Article,
|
||||||
PricelistID: pricelistID,
|
PricelistID: pricelistID,
|
||||||
OnlyInStock: req.OnlyInStock,
|
OnlyInStock: req.OnlyInStock,
|
||||||
}
|
}
|
||||||
@@ -146,6 +159,9 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
|
|||||||
config.Notes = req.Notes
|
config.Notes = req.Notes
|
||||||
config.IsTemplate = req.IsTemplate
|
config.IsTemplate = req.IsTemplate
|
||||||
config.ServerCount = req.ServerCount
|
config.ServerCount = req.ServerCount
|
||||||
|
config.ServerModel = req.ServerModel
|
||||||
|
config.SupportCode = req.SupportCode
|
||||||
|
config.Article = req.Article
|
||||||
config.PricelistID = pricelistID
|
config.PricelistID = pricelistID
|
||||||
config.OnlyInStock = req.OnlyInStock
|
config.OnlyInStock = req.OnlyInStock
|
||||||
|
|
||||||
|
|||||||
@@ -5,34 +5,31 @@ import (
|
|||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ExportService struct {
|
type ExportService struct {
|
||||||
config config.ExportConfig
|
config config.ExportConfig
|
||||||
categoryRepo *repository.CategoryRepository
|
categoryRepo *repository.CategoryRepository
|
||||||
|
localDB *localdb.LocalDB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExportService(cfg config.ExportConfig, categoryRepo *repository.CategoryRepository) *ExportService {
|
func NewExportService(cfg config.ExportConfig, categoryRepo *repository.CategoryRepository, local *localdb.LocalDB) *ExportService {
|
||||||
return &ExportService{
|
return &ExportService{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
categoryRepo: categoryRepo,
|
categoryRepo: categoryRepo,
|
||||||
|
localDB: local,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExportData struct {
|
// ExportItem represents a single component in an export block.
|
||||||
Name string
|
|
||||||
Items []ExportItem
|
|
||||||
Total float64
|
|
||||||
Notes string
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExportItem struct {
|
type ExportItem struct {
|
||||||
LotName string
|
LotName string
|
||||||
Description string
|
Description string
|
||||||
@@ -42,19 +39,43 @@ type ExportItem struct {
|
|||||||
TotalPrice float64
|
TotalPrice float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExportService) ToCSV(w io.Writer, data *ExportData) error {
|
// ConfigExportBlock represents one configuration (server) in the export.
|
||||||
|
type ConfigExportBlock struct {
|
||||||
|
Article string
|
||||||
|
ServerCount int
|
||||||
|
UnitPrice float64 // sum of component prices for one server
|
||||||
|
Items []ExportItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectExportData holds all configuration blocks for a project-level export.
|
||||||
|
type ProjectExportData struct {
|
||||||
|
Configs []ConfigExportBlock
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToCSV writes project export data in the new structured CSV format.
|
||||||
|
//
|
||||||
|
// Format:
|
||||||
|
//
|
||||||
|
// Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);Price (1 pcs.);Price (total)
|
||||||
|
// 10;;DL380-ARTICLE;;;10;10470;104 700
|
||||||
|
// ;;MB_INTEL_...;;1;;2074,5;
|
||||||
|
// ...
|
||||||
|
// (empty row)
|
||||||
|
// 20;;DL380-ARTICLE-2;;;2;10470;20 940
|
||||||
|
// ...
|
||||||
|
func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
|
||||||
// Write UTF-8 BOM for Excel compatibility
|
// Write UTF-8 BOM for Excel compatibility
|
||||||
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||||
return fmt.Errorf("failed to write BOM: %w", err)
|
return fmt.Errorf("failed to write BOM: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
csvWriter := csv.NewWriter(w)
|
csvWriter := csv.NewWriter(w)
|
||||||
// Use semicolon as delimiter for Russian Excel locale
|
|
||||||
csvWriter.Comma = ';'
|
csvWriter.Comma = ';'
|
||||||
defer csvWriter.Flush()
|
defer csvWriter.Flush()
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
headers := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
|
headers := []string{"Line", "Type", "p/n", "Description", "Qty (1 pcs.)", "Qty (total)", "Price (1 pcs.)", "Price (total)"}
|
||||||
if err := csvWriter.Write(headers); err != nil {
|
if err := csvWriter.Write(headers); err != nil {
|
||||||
return fmt.Errorf("failed to write header: %w", err)
|
return fmt.Errorf("failed to write header: %w", err)
|
||||||
}
|
}
|
||||||
@@ -70,47 +91,59 @@ func (s *ExportService) ToCSV(w io.Writer, data *ExportData) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort items by category display order
|
for i, block := range data.Configs {
|
||||||
sortedItems := make([]ExportItem, len(data.Items))
|
lineNo := (i + 1) * 10
|
||||||
copy(sortedItems, data.Items)
|
|
||||||
|
|
||||||
// Sort using category display order (items without category go to the end)
|
serverCount := block.ServerCount
|
||||||
for i := 0; i < len(sortedItems)-1; i++ {
|
if serverCount < 1 {
|
||||||
for j := i + 1; j < len(sortedItems); j++ {
|
serverCount = 1
|
||||||
orderI, hasI := categoryOrder[sortedItems[i].Category]
|
}
|
||||||
orderJ, hasJ := categoryOrder[sortedItems[j].Category]
|
|
||||||
|
|
||||||
// Items without category go to the end
|
totalPrice := block.UnitPrice * float64(serverCount)
|
||||||
if !hasI && hasJ {
|
|
||||||
sortedItems[i], sortedItems[j] = sortedItems[j], sortedItems[i]
|
// Server summary row
|
||||||
} else if hasI && hasJ {
|
serverRow := []string{
|
||||||
// Both have categories, sort by display order
|
fmt.Sprintf("%d", lineNo), // Line
|
||||||
if orderI > orderJ {
|
"", // Type
|
||||||
sortedItems[i], sortedItems[j] = sortedItems[j], sortedItems[i]
|
block.Article, // p/n
|
||||||
}
|
"", // Description
|
||||||
|
"", // Qty (1 pcs.)
|
||||||
|
fmt.Sprintf("%d", serverCount), // Qty (total)
|
||||||
|
formatPriceInt(block.UnitPrice), // Price (1 pcs.)
|
||||||
|
formatPriceWithSpace(totalPrice), // Price (total)
|
||||||
|
}
|
||||||
|
if err := csvWriter.Write(serverRow); err != nil {
|
||||||
|
return fmt.Errorf("failed to write server row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort items by category display order
|
||||||
|
sortedItems := make([]ExportItem, len(block.Items))
|
||||||
|
copy(sortedItems, block.Items)
|
||||||
|
sortItemsByCategory(sortedItems, categoryOrder)
|
||||||
|
|
||||||
|
// Component rows
|
||||||
|
for _, item := range sortedItems {
|
||||||
|
componentRow := []string{
|
||||||
|
"", // Line
|
||||||
|
item.Category, // Type
|
||||||
|
item.LotName, // p/n
|
||||||
|
"", // Description
|
||||||
|
fmt.Sprintf("%d", item.Quantity), // Qty (1 pcs.)
|
||||||
|
"", // Qty (total)
|
||||||
|
formatPriceComma(item.UnitPrice), // Price (1 pcs.)
|
||||||
|
"", // Price (total)
|
||||||
|
}
|
||||||
|
if err := csvWriter.Write(componentRow); err != nil {
|
||||||
|
return fmt.Errorf("failed to write component row: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Items
|
// Empty separator row between blocks (skip after last)
|
||||||
for _, item := range sortedItems {
|
if i < len(data.Configs)-1 {
|
||||||
row := []string{
|
if err := csvWriter.Write([]string{"", "", "", "", "", "", "", ""}); err != nil {
|
||||||
item.LotName,
|
return fmt.Errorf("failed to write separator row: %w", err)
|
||||||
item.Description,
|
}
|
||||||
item.Category,
|
|
||||||
fmt.Sprintf("%d", item.Quantity),
|
|
||||||
strings.ReplaceAll(fmt.Sprintf("%.2f", item.UnitPrice), ".", ","),
|
|
||||||
strings.ReplaceAll(fmt.Sprintf("%.2f", item.TotalPrice), ".", ","),
|
|
||||||
}
|
}
|
||||||
if err := csvWriter.Write(row); err != nil {
|
|
||||||
return fmt.Errorf("failed to write row: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Total row
|
|
||||||
totalStr := strings.ReplaceAll(fmt.Sprintf("%.2f", data.Total), ".", ",")
|
|
||||||
if err := csvWriter.Write([]string{"", "", "", "", "ИТОГО:", totalStr}); err != nil {
|
|
||||||
return fmt.Errorf("failed to write total row: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
csvWriter.Flush()
|
csvWriter.Flush()
|
||||||
@@ -121,8 +154,8 @@ func (s *ExportService) ToCSV(w io.Writer, data *ExportData) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToCSVBytes is a backward-compatible wrapper that returns CSV data as bytes
|
// ToCSVBytes is a backward-compatible wrapper that returns CSV data as bytes.
|
||||||
func (s *ExportService) ToCSVBytes(data *ExportData) ([]byte, error) {
|
func (s *ExportService) ToCSVBytes(data *ProjectExportData) ([]byte, error) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := s.ToCSV(&buf, data); err != nil {
|
if err := s.ToCSV(&buf, data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -130,41 +163,163 @@ func (s *ExportService) ToCSVBytes(data *ExportData) ([]byte, error) {
|
|||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExportService) ConfigToExportData(config *models.Configuration, componentService *ComponentService) *ExportData {
|
// ConfigToExportData converts a single configuration into ProjectExportData.
|
||||||
items := make([]ExportItem, len(config.Items))
|
func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectExportData {
|
||||||
var total float64
|
block := s.buildExportBlock(cfg)
|
||||||
|
return &ProjectExportData{
|
||||||
for i, item := range config.Items {
|
Configs: []ConfigExportBlock{block},
|
||||||
itemTotal := item.UnitPrice * float64(item.Quantity)
|
CreatedAt: cfg.CreatedAt,
|
||||||
|
|
||||||
// Получаем информацию о компоненте для заполнения категории
|
|
||||||
componentView, err := componentService.GetByLotName(item.LotName)
|
|
||||||
if err != nil {
|
|
||||||
// Если не удалось получить информацию о компоненте, используем только основные данные
|
|
||||||
items[i] = ExportItem{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: item.UnitPrice,
|
|
||||||
TotalPrice: itemTotal,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
items[i] = ExportItem{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Description: componentView.Description,
|
|
||||||
Category: componentView.Category,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: item.UnitPrice,
|
|
||||||
TotalPrice: itemTotal,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
total += itemTotal
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ExportData{
|
|
||||||
Name: config.Name,
|
|
||||||
Items: items,
|
|
||||||
Total: total,
|
|
||||||
Notes: config.Notes,
|
|
||||||
CreatedAt: config.CreatedAt,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProjectToExportData converts multiple configurations into ProjectExportData.
|
||||||
|
func (s *ExportService) ProjectToExportData(configs []models.Configuration) *ProjectExportData {
|
||||||
|
blocks := make([]ConfigExportBlock, 0, len(configs))
|
||||||
|
for i := range configs {
|
||||||
|
blocks = append(blocks, s.buildExportBlock(&configs[i]))
|
||||||
|
}
|
||||||
|
return &ProjectExportData{
|
||||||
|
Configs: blocks,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExportBlock {
|
||||||
|
// Batch-fetch categories from local data (pricelist items → local_components fallback)
|
||||||
|
lotNames := make([]string, len(cfg.Items))
|
||||||
|
for i, item := range cfg.Items {
|
||||||
|
lotNames[i] = item.LotName
|
||||||
|
}
|
||||||
|
categories := s.resolveCategories(cfg.PricelistID, lotNames)
|
||||||
|
|
||||||
|
items := make([]ExportItem, len(cfg.Items))
|
||||||
|
var unitTotal float64
|
||||||
|
|
||||||
|
for i, item := range cfg.Items {
|
||||||
|
itemTotal := item.UnitPrice * float64(item.Quantity)
|
||||||
|
items[i] = ExportItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Category: categories[item.LotName],
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
TotalPrice: itemTotal,
|
||||||
|
}
|
||||||
|
unitTotal += itemTotal
|
||||||
|
}
|
||||||
|
|
||||||
|
serverCount := cfg.ServerCount
|
||||||
|
if serverCount < 1 {
|
||||||
|
serverCount = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConfigExportBlock{
|
||||||
|
Article: cfg.Article,
|
||||||
|
ServerCount: serverCount,
|
||||||
|
UnitPrice: unitTotal,
|
||||||
|
Items: items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCategories returns lot_name → category map.
|
||||||
|
// Primary source: pricelist items (lot_category). Fallback: local_components table.
|
||||||
|
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
|
||||||
|
if len(lotNames) == 0 || s.localDB == nil {
|
||||||
|
return map[string]string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
categories := make(map[string]string, len(lotNames))
|
||||||
|
|
||||||
|
// Primary: pricelist items
|
||||||
|
if pricelistID != nil && *pricelistID > 0 {
|
||||||
|
if cats, err := s.localDB.GetLocalLotCategoriesByServerPricelistID(*pricelistID, lotNames); err == nil {
|
||||||
|
for lot, cat := range cats {
|
||||||
|
if strings.TrimSpace(cat) != "" {
|
||||||
|
categories[lot] = cat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: local_components for any still missing
|
||||||
|
var missing []string
|
||||||
|
for _, lot := range lotNames {
|
||||||
|
if categories[lot] == "" {
|
||||||
|
missing = append(missing, lot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
if fallback, err := s.localDB.GetLocalComponentCategoriesByLotNames(missing); err == nil {
|
||||||
|
for lot, cat := range fallback {
|
||||||
|
if strings.TrimSpace(cat) != "" {
|
||||||
|
categories[lot] = cat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortItemsByCategory sorts items by category display order (items without category go to the end).
|
||||||
|
func sortItemsByCategory(items []ExportItem, categoryOrder map[string]int) {
|
||||||
|
for i := 0; i < len(items)-1; i++ {
|
||||||
|
for j := i + 1; j < len(items); j++ {
|
||||||
|
orderI, hasI := categoryOrder[items[i].Category]
|
||||||
|
orderJ, hasJ := categoryOrder[items[j].Category]
|
||||||
|
|
||||||
|
if !hasI && hasJ {
|
||||||
|
items[i], items[j] = items[j], items[i]
|
||||||
|
} else if hasI && hasJ && orderI > orderJ {
|
||||||
|
items[i], items[j] = items[j], items[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatPriceComma formats a price with comma as decimal separator (e.g., "2074,5").
|
||||||
|
// Trailing zeros after the comma are trimmed, and if the value is an integer, no comma is shown.
|
||||||
|
func formatPriceComma(value float64) string {
|
||||||
|
if value == math.Trunc(value) {
|
||||||
|
return fmt.Sprintf("%.0f", value)
|
||||||
|
}
|
||||||
|
s := fmt.Sprintf("%.2f", value)
|
||||||
|
s = strings.ReplaceAll(s, ".", ",")
|
||||||
|
// Trim trailing zero: "2074,50" -> "2074,5"
|
||||||
|
s = strings.TrimRight(s, "0")
|
||||||
|
s = strings.TrimRight(s, ",")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatPriceInt formats price as integer (rounded), no decimal.
|
||||||
|
func formatPriceInt(value float64) string {
|
||||||
|
return fmt.Sprintf("%.0f", math.Round(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatPriceWithSpace formats a price as an integer with space as thousands separator (e.g., "104 700").
|
||||||
|
func formatPriceWithSpace(value float64) string {
|
||||||
|
intVal := int64(math.Round(value))
|
||||||
|
if intVal < 0 {
|
||||||
|
return "-" + formatIntWithSpace(-intVal)
|
||||||
|
}
|
||||||
|
return formatIntWithSpace(intVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatIntWithSpace(n int64) string {
|
||||||
|
s := fmt.Sprintf("%d", n)
|
||||||
|
if len(s) <= 3 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
remainder := len(s) % 3
|
||||||
|
if remainder > 0 {
|
||||||
|
result.WriteString(s[:remainder])
|
||||||
|
}
|
||||||
|
for i := remainder; i < len(s); i += 3 {
|
||||||
|
if result.Len() > 0 {
|
||||||
|
result.WriteByte(' ')
|
||||||
|
}
|
||||||
|
result.WriteString(s[i : i+3])
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,25 +10,39 @@ import (
|
|||||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func newTestProjectData(items []ExportItem, article string, serverCount int) *ProjectExportData {
|
||||||
func TestToCSV_UTF8BOM(t *testing.T) {
|
var unitTotal float64
|
||||||
svc := NewExportService(config.ExportConfig{}, nil)
|
for _, item := range items {
|
||||||
|
unitTotal += item.UnitPrice * float64(item.Quantity)
|
||||||
data := &ExportData{
|
}
|
||||||
Name: "Test",
|
if serverCount < 1 {
|
||||||
Items: []ExportItem{
|
serverCount = 1
|
||||||
|
}
|
||||||
|
return &ProjectExportData{
|
||||||
|
Configs: []ConfigExportBlock{
|
||||||
{
|
{
|
||||||
LotName: "LOT-001",
|
Article: article,
|
||||||
Description: "Test Item",
|
ServerCount: serverCount,
|
||||||
Category: "CAT",
|
UnitPrice: unitTotal,
|
||||||
Quantity: 1,
|
Items: items,
|
||||||
UnitPrice: 100.0,
|
|
||||||
TotalPrice: 100.0,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Total: 100.0,
|
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToCSV_UTF8BOM(t *testing.T) {
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
|
|
||||||
|
data := newTestProjectData([]ExportItem{
|
||||||
|
{
|
||||||
|
LotName: "LOT-001",
|
||||||
|
Category: "CAT",
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: 100.0,
|
||||||
|
TotalPrice: 100.0,
|
||||||
|
},
|
||||||
|
}, "TEST-ARTICLE", 1)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := svc.ToCSV(&buf, data); err != nil {
|
if err := svc.ToCSV(&buf, data); err != nil {
|
||||||
@@ -40,40 +54,31 @@ func TestToCSV_UTF8BOM(t *testing.T) {
|
|||||||
t.Fatalf("CSV too short to contain BOM")
|
t.Fatalf("CSV too short to contain BOM")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check UTF-8 BOM: 0xEF 0xBB 0xBF
|
|
||||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||||
actualBOM := csvBytes[:3]
|
actualBOM := csvBytes[:3]
|
||||||
|
if !bytes.Equal(actualBOM, expectedBOM) {
|
||||||
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
|
||||||
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToCSV_SemicolonDelimiter(t *testing.T) {
|
func TestToCSV_SemicolonDelimiter(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil)
|
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
|
|
||||||
data := &ExportData{
|
data := newTestProjectData([]ExportItem{
|
||||||
Name: "Test",
|
{
|
||||||
Items: []ExportItem{
|
LotName: "LOT-001",
|
||||||
{
|
Category: "CAT",
|
||||||
LotName: "LOT-001",
|
Quantity: 2,
|
||||||
Description: "Test Item",
|
UnitPrice: 100.50,
|
||||||
Category: "CAT",
|
TotalPrice: 201.00,
|
||||||
Quantity: 2,
|
|
||||||
UnitPrice: 100.50,
|
|
||||||
TotalPrice: 201.00,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Total: 201.00,
|
}, "TEST-ARTICLE", 1)
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := svc.ToCSV(&buf, data); err != nil {
|
if err := svc.ToCSV(&buf, data); err != nil {
|
||||||
t.Fatalf("ToCSV failed: %v", err)
|
t.Fatalf("ToCSV failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip BOM and read CSV with semicolon delimiter
|
|
||||||
csvBytes := buf.Bytes()
|
csvBytes := buf.Bytes()
|
||||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||||
reader.Comma = ';'
|
reader.Comma = ';'
|
||||||
@@ -84,125 +89,52 @@ func TestToCSV_SemicolonDelimiter(t *testing.T) {
|
|||||||
t.Fatalf("Failed to read header: %v", err)
|
t.Fatalf("Failed to read header: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(header) != 6 {
|
if len(header) != 8 {
|
||||||
t.Errorf("Expected 6 columns, got %d", len(header))
|
t.Errorf("Expected 8 columns, got %d", len(header))
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedHeader := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
|
expectedHeader := []string{"Line", "Type", "p/n", "Description", "Qty (1 pcs.)", "Qty (total)", "Price (1 pcs.)", "Price (total)"}
|
||||||
for i, col := range expectedHeader {
|
for i, col := range expectedHeader {
|
||||||
if i < len(header) && header[i] != col {
|
if i < len(header) && header[i] != col {
|
||||||
t.Errorf("Column %d: expected %q, got %q", i, col, header[i])
|
t.Errorf("Column %d: expected %q, got %q", i, col, header[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read item row
|
// Read server row
|
||||||
|
serverRow, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read server row: %v", err)
|
||||||
|
}
|
||||||
|
if serverRow[0] != "10" {
|
||||||
|
t.Errorf("Expected line number 10, got %s", serverRow[0])
|
||||||
|
}
|
||||||
|
if serverRow[2] != "TEST-ARTICLE" {
|
||||||
|
t.Errorf("Expected article TEST-ARTICLE, got %s", serverRow[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read component row
|
||||||
itemRow, err := reader.Read()
|
itemRow, err := reader.Read()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to read item row: %v", err)
|
t.Fatalf("Failed to read item row: %v", err)
|
||||||
}
|
}
|
||||||
|
if itemRow[2] != "LOT-001" {
|
||||||
if itemRow[0] != "LOT-001" {
|
t.Errorf("Lot name mismatch: expected LOT-001, got %s", itemRow[2])
|
||||||
t.Errorf("Lot name mismatch: expected LOT-001, got %s", itemRow[0])
|
|
||||||
}
|
}
|
||||||
|
if itemRow[4] != "2" {
|
||||||
if itemRow[3] != "2" {
|
t.Errorf("Quantity mismatch: expected 2, got %s", itemRow[4])
|
||||||
t.Errorf("Quantity mismatch: expected 2, got %s", itemRow[3])
|
|
||||||
}
|
}
|
||||||
|
if itemRow[6] != "100,5" {
|
||||||
if itemRow[4] != "100,50" {
|
t.Errorf("Unit price mismatch: expected 100,5, got %s", itemRow[6])
|
||||||
t.Errorf("Unit price mismatch: expected 100,50, got %s", itemRow[4])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToCSV_TotalRow(t *testing.T) {
|
func TestToCSV_ServerRow(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil)
|
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
|
|
||||||
data := &ExportData{
|
data := newTestProjectData([]ExportItem{
|
||||||
Name: "Test",
|
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||||
Items: []ExportItem{
|
{LotName: "LOT-002", Category: "CAT", Quantity: 2, UnitPrice: 50.0, TotalPrice: 100.0},
|
||||||
{
|
}, "DL380-ART", 10)
|
||||||
LotName: "LOT-001",
|
|
||||||
Description: "Item 1",
|
|
||||||
Category: "CAT",
|
|
||||||
Quantity: 1,
|
|
||||||
UnitPrice: 100.0,
|
|
||||||
TotalPrice: 100.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
LotName: "LOT-002",
|
|
||||||
Description: "Item 2",
|
|
||||||
Category: "CAT",
|
|
||||||
Quantity: 2,
|
|
||||||
UnitPrice: 50.0,
|
|
||||||
TotalPrice: 100.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Total: 200.0,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := svc.ToCSV(&buf, data); err != nil {
|
|
||||||
t.Fatalf("ToCSV failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
csvBytes := buf.Bytes()
|
|
||||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
|
||||||
reader.Comma = ';'
|
|
||||||
|
|
||||||
// Skip header and item rows
|
|
||||||
reader.Read()
|
|
||||||
reader.Read()
|
|
||||||
reader.Read()
|
|
||||||
|
|
||||||
// Read total row
|
|
||||||
totalRow, err := reader.Read()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read total row: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Total row should have "ИТОГО:" in position 4 and total value in position 5
|
|
||||||
if totalRow[4] != "ИТОГО:" {
|
|
||||||
t.Errorf("Expected 'ИТОГО:' in column 4, got %q", totalRow[4])
|
|
||||||
}
|
|
||||||
|
|
||||||
if totalRow[5] != "200,00" {
|
|
||||||
t.Errorf("Expected total 200,00, got %s", totalRow[5])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToCSV_CategorySorting(t *testing.T) {
|
|
||||||
// Test category sorting without category repo (items maintain original order)
|
|
||||||
svc := NewExportService(config.ExportConfig{}, nil)
|
|
||||||
|
|
||||||
data := &ExportData{
|
|
||||||
Name: "Test",
|
|
||||||
Items: []ExportItem{
|
|
||||||
{
|
|
||||||
LotName: "LOT-001",
|
|
||||||
Category: "CAT-A",
|
|
||||||
Quantity: 1,
|
|
||||||
UnitPrice: 100.0,
|
|
||||||
TotalPrice: 100.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
LotName: "LOT-002",
|
|
||||||
Category: "CAT-C",
|
|
||||||
Quantity: 1,
|
|
||||||
UnitPrice: 100.0,
|
|
||||||
TotalPrice: 100.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
LotName: "LOT-003",
|
|
||||||
Category: "CAT-B",
|
|
||||||
Quantity: 1,
|
|
||||||
UnitPrice: 100.0,
|
|
||||||
TotalPrice: 100.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Total: 300.0,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := svc.ToCSV(&buf, data); err != nil {
|
if err := svc.ToCSV(&buf, data); err != nil {
|
||||||
@@ -216,30 +148,75 @@ func TestToCSV_CategorySorting(t *testing.T) {
|
|||||||
// Skip header
|
// Skip header
|
||||||
reader.Read()
|
reader.Read()
|
||||||
|
|
||||||
|
// Read server row
|
||||||
|
serverRow, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read server row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if serverRow[0] != "10" {
|
||||||
|
t.Errorf("Expected line 10, got %s", serverRow[0])
|
||||||
|
}
|
||||||
|
if serverRow[2] != "DL380-ART" {
|
||||||
|
t.Errorf("Expected article DL380-ART, got %s", serverRow[2])
|
||||||
|
}
|
||||||
|
if serverRow[5] != "10" {
|
||||||
|
t.Errorf("Expected server count 10, got %s", serverRow[5])
|
||||||
|
}
|
||||||
|
// UnitPrice = 100 + 100 = 200
|
||||||
|
if serverRow[6] != "200" {
|
||||||
|
t.Errorf("Expected unit price 200, got %s", serverRow[6])
|
||||||
|
}
|
||||||
|
// TotalPrice = 200 * 10 = 2000
|
||||||
|
if serverRow[7] != "2 000" {
|
||||||
|
t.Errorf("Expected total price '2 000', got %q", serverRow[7])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToCSV_CategorySorting(t *testing.T) {
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
|
|
||||||
|
data := newTestProjectData([]ExportItem{
|
||||||
|
{LotName: "LOT-001", Category: "CAT-A", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||||
|
{LotName: "LOT-002", Category: "CAT-C", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||||
|
{LotName: "LOT-003", Category: "CAT-B", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||||
|
}, "ART", 1)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := svc.ToCSV(&buf, data); err != nil {
|
||||||
|
t.Fatalf("ToCSV failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csvBytes := buf.Bytes()
|
||||||
|
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||||
|
reader.Comma = ';'
|
||||||
|
|
||||||
|
// Skip header and server row
|
||||||
|
reader.Read()
|
||||||
|
reader.Read()
|
||||||
|
|
||||||
// Without category repo, items maintain original order
|
// Without category repo, items maintain original order
|
||||||
row1, _ := reader.Read()
|
row1, _ := reader.Read()
|
||||||
if row1[0] != "LOT-001" {
|
if row1[2] != "LOT-001" {
|
||||||
t.Errorf("Expected LOT-001 first, got %s", row1[0])
|
t.Errorf("Expected LOT-001 first, got %s", row1[2])
|
||||||
}
|
}
|
||||||
|
|
||||||
row2, _ := reader.Read()
|
row2, _ := reader.Read()
|
||||||
if row2[0] != "LOT-002" {
|
if row2[2] != "LOT-002" {
|
||||||
t.Errorf("Expected LOT-002 second, got %s", row2[0])
|
t.Errorf("Expected LOT-002 second, got %s", row2[2])
|
||||||
}
|
}
|
||||||
|
|
||||||
row3, _ := reader.Read()
|
row3, _ := reader.Read()
|
||||||
if row3[0] != "LOT-003" {
|
if row3[2] != "LOT-003" {
|
||||||
t.Errorf("Expected LOT-003 third, got %s", row3[0])
|
t.Errorf("Expected LOT-003 third, got %s", row3[2])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToCSV_EmptyData(t *testing.T) {
|
func TestToCSV_EmptyData(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil)
|
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
|
|
||||||
data := &ExportData{
|
data := &ProjectExportData{
|
||||||
Name: "Test",
|
Configs: []ConfigExportBlock{},
|
||||||
Items: []ExportItem{},
|
|
||||||
Total: 0.0,
|
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,44 +229,28 @@ func TestToCSV_EmptyData(t *testing.T) {
|
|||||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||||
reader.Comma = ';'
|
reader.Comma = ';'
|
||||||
|
|
||||||
// Should have header and total row
|
|
||||||
header, err := reader.Read()
|
header, err := reader.Read()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to read header: %v", err)
|
t.Fatalf("Failed to read header: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(header) != 6 {
|
if len(header) != 8 {
|
||||||
t.Errorf("Expected 6 columns, got %d", len(header))
|
t.Errorf("Expected 8 columns, got %d", len(header))
|
||||||
}
|
}
|
||||||
|
|
||||||
totalRow, err := reader.Read()
|
// No more rows expected
|
||||||
if err != nil {
|
_, err = reader.Read()
|
||||||
t.Fatalf("Failed to read total row: %v", err)
|
if err != io.EOF {
|
||||||
}
|
t.Errorf("Expected EOF after header, got: %v", err)
|
||||||
|
|
||||||
if totalRow[4] != "ИТОГО:" {
|
|
||||||
t.Errorf("Expected ИТОГО: in total row, got %s", totalRow[4])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToCSVBytes_BackwardCompat(t *testing.T) {
|
func TestToCSVBytes_BackwardCompat(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil)
|
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
|
|
||||||
data := &ExportData{
|
data := newTestProjectData([]ExportItem{
|
||||||
Name: "Test",
|
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||||
Items: []ExportItem{
|
}, "ART", 1)
|
||||||
{
|
|
||||||
LotName: "LOT-001",
|
|
||||||
Description: "Test Item",
|
|
||||||
Category: "CAT",
|
|
||||||
Quantity: 1,
|
|
||||||
UnitPrice: 100.0,
|
|
||||||
TotalPrice: 100.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Total: 100.0,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
csvBytes, err := svc.ToCSVBytes(data)
|
csvBytes, err := svc.ToCSVBytes(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -300,34 +261,20 @@ func TestToCSVBytes_BackwardCompat(t *testing.T) {
|
|||||||
t.Fatalf("CSV bytes too short")
|
t.Fatalf("CSV bytes too short")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify BOM is present
|
|
||||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||||
actualBOM := csvBytes[:3]
|
actualBOM := csvBytes[:3]
|
||||||
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
if !bytes.Equal(actualBOM, expectedBOM) {
|
||||||
t.Errorf("UTF-8 BOM mismatch in ToCSVBytes")
|
t.Errorf("UTF-8 BOM mismatch in ToCSVBytes")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToCSV_WriterError(t *testing.T) {
|
func TestToCSV_WriterError(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil)
|
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
|
|
||||||
data := &ExportData{
|
data := newTestProjectData([]ExportItem{
|
||||||
Name: "Test",
|
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||||
Items: []ExportItem{
|
}, "ART", 1)
|
||||||
{
|
|
||||||
LotName: "LOT-001",
|
|
||||||
Description: "Test",
|
|
||||||
Category: "CAT",
|
|
||||||
Quantity: 1,
|
|
||||||
UnitPrice: 100.0,
|
|
||||||
TotalPrice: 100.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Total: 100.0,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a failing writer
|
|
||||||
failingWriter := &failingWriter{}
|
failingWriter := &failingWriter{}
|
||||||
|
|
||||||
if err := svc.ToCSV(failingWriter, data); err == nil {
|
if err := svc.ToCSV(failingWriter, data); err == nil {
|
||||||
@@ -335,6 +282,122 @@ func TestToCSV_WriterError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestToCSV_MultipleBlocks(t *testing.T) {
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
|
|
||||||
|
data := &ProjectExportData{
|
||||||
|
Configs: []ConfigExportBlock{
|
||||||
|
{
|
||||||
|
Article: "ART-1",
|
||||||
|
ServerCount: 2,
|
||||||
|
UnitPrice: 500.0,
|
||||||
|
Items: []ExportItem{
|
||||||
|
{LotName: "LOT-A", Category: "CPU", Quantity: 1, UnitPrice: 500.0, TotalPrice: 500.0},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Article: "ART-2",
|
||||||
|
ServerCount: 3,
|
||||||
|
UnitPrice: 1000.0,
|
||||||
|
Items: []ExportItem{
|
||||||
|
{LotName: "LOT-B", Category: "MEM", Quantity: 2, UnitPrice: 500.0, TotalPrice: 1000.0},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := svc.ToCSV(&buf, data); err != nil {
|
||||||
|
t.Fatalf("ToCSV failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csvBytes := buf.Bytes()
|
||||||
|
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||||
|
reader.Comma = ';'
|
||||||
|
reader.FieldsPerRecord = -1 // allow variable fields
|
||||||
|
|
||||||
|
// Header
|
||||||
|
reader.Read()
|
||||||
|
|
||||||
|
// Block 1: server row
|
||||||
|
srv1, _ := reader.Read()
|
||||||
|
if srv1[0] != "10" {
|
||||||
|
t.Errorf("Block 1 line: expected 10, got %s", srv1[0])
|
||||||
|
}
|
||||||
|
if srv1[7] != "1 000" {
|
||||||
|
t.Errorf("Block 1 total: expected '1 000', got %q", srv1[7])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block 1: component row
|
||||||
|
comp1, _ := reader.Read()
|
||||||
|
if comp1[2] != "LOT-A" {
|
||||||
|
t.Errorf("Block 1 component: expected LOT-A, got %s", comp1[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separator row
|
||||||
|
sep, _ := reader.Read()
|
||||||
|
allEmpty := true
|
||||||
|
for _, v := range sep {
|
||||||
|
if v != "" {
|
||||||
|
allEmpty = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allEmpty {
|
||||||
|
t.Errorf("Expected empty separator row, got %v", sep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block 2: server row
|
||||||
|
srv2, _ := reader.Read()
|
||||||
|
if srv2[0] != "20" {
|
||||||
|
t.Errorf("Block 2 line: expected 20, got %s", srv2[0])
|
||||||
|
}
|
||||||
|
if srv2[7] != "3 000" {
|
||||||
|
t.Errorf("Block 2 total: expected '3 000', got %q", srv2[7])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatPriceWithSpace(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input float64
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{0, "0"},
|
||||||
|
{100, "100"},
|
||||||
|
{1000, "1 000"},
|
||||||
|
{10470, "10 470"},
|
||||||
|
{104700, "104 700"},
|
||||||
|
{1000000, "1 000 000"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
result := formatPriceWithSpace(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("formatPriceWithSpace(%v): expected %q, got %q", tt.input, tt.expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatPriceComma(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input float64
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{100.0, "100"},
|
||||||
|
{2074.5, "2074,5"},
|
||||||
|
{100.50, "100,5"},
|
||||||
|
{99.99, "99,99"},
|
||||||
|
{0, "0"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
result := formatPriceComma(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("formatPriceComma(%v): expected %q, got %q", tt.input, tt.expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// failingWriter always returns an error
|
// failingWriter always returns an error
|
||||||
type failingWriter struct{}
|
type failingWriter struct{}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/article"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
@@ -64,6 +65,18 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.ServerModel) != "" {
|
||||||
|
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
|
||||||
|
ServerModel: req.ServerModel,
|
||||||
|
SupportCode: req.SupportCode,
|
||||||
|
ServerPricelist: pricelistID,
|
||||||
|
})
|
||||||
|
if articleErr != nil {
|
||||||
|
return nil, articleErr
|
||||||
|
}
|
||||||
|
req.Article = articleResult.Article
|
||||||
|
}
|
||||||
|
|
||||||
total := req.Items.Total()
|
total := req.Items.Total()
|
||||||
if req.ServerCount > 1 {
|
if req.ServerCount > 1 {
|
||||||
total *= float64(req.ServerCount)
|
total *= float64(req.ServerCount)
|
||||||
@@ -80,6 +93,9 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
|||||||
Notes: req.Notes,
|
Notes: req.Notes,
|
||||||
IsTemplate: req.IsTemplate,
|
IsTemplate: req.IsTemplate,
|
||||||
ServerCount: req.ServerCount,
|
ServerCount: req.ServerCount,
|
||||||
|
ServerModel: req.ServerModel,
|
||||||
|
SupportCode: req.SupportCode,
|
||||||
|
Article: req.Article,
|
||||||
PricelistID: pricelistID,
|
PricelistID: pricelistID,
|
||||||
OnlyInStock: req.OnlyInStock,
|
OnlyInStock: req.OnlyInStock,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
@@ -142,6 +158,18 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.ServerModel) != "" {
|
||||||
|
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
|
||||||
|
ServerModel: req.ServerModel,
|
||||||
|
SupportCode: req.SupportCode,
|
||||||
|
ServerPricelist: pricelistID,
|
||||||
|
})
|
||||||
|
if articleErr != nil {
|
||||||
|
return nil, articleErr
|
||||||
|
}
|
||||||
|
req.Article = articleResult.Article
|
||||||
|
}
|
||||||
|
|
||||||
total := req.Items.Total()
|
total := req.Items.Total()
|
||||||
if req.ServerCount > 1 {
|
if req.ServerCount > 1 {
|
||||||
total *= float64(req.ServerCount)
|
total *= float64(req.ServerCount)
|
||||||
@@ -163,6 +191,9 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
|||||||
localCfg.Notes = req.Notes
|
localCfg.Notes = req.Notes
|
||||||
localCfg.IsTemplate = req.IsTemplate
|
localCfg.IsTemplate = req.IsTemplate
|
||||||
localCfg.ServerCount = req.ServerCount
|
localCfg.ServerCount = req.ServerCount
|
||||||
|
localCfg.ServerModel = req.ServerModel
|
||||||
|
localCfg.SupportCode = req.SupportCode
|
||||||
|
localCfg.Article = req.Article
|
||||||
localCfg.PricelistID = pricelistID
|
localCfg.PricelistID = pricelistID
|
||||||
localCfg.OnlyInStock = req.OnlyInStock
|
localCfg.OnlyInStock = req.OnlyInStock
|
||||||
localCfg.UpdatedAt = time.Now()
|
localCfg.UpdatedAt = time.Now()
|
||||||
@@ -176,6 +207,19 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildArticlePreview generates server article based on current items and server_model/support_code.
|
||||||
|
func (s *LocalConfigurationService) BuildArticlePreview(req *ArticlePreviewRequest) (article.BuildResult, error) {
|
||||||
|
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||||
|
if err != nil {
|
||||||
|
return article.BuildResult{}, err
|
||||||
|
}
|
||||||
|
return article.Build(s.localDB, req.Items, article.BuildOptions{
|
||||||
|
ServerModel: req.ServerModel,
|
||||||
|
SupportCode: req.SupportCode,
|
||||||
|
ServerPricelist: pricelistID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Delete deletes a configuration from local SQLite and queues it for sync
|
// Delete deletes a configuration from local SQLite and queues it for sync
|
||||||
func (s *LocalConfigurationService) Delete(uuid string, ownerUsername string) error {
|
func (s *LocalConfigurationService) Delete(uuid string, ownerUsername string) error {
|
||||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
@@ -269,6 +313,9 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
|
|||||||
Notes: original.Notes,
|
Notes: original.Notes,
|
||||||
IsTemplate: false,
|
IsTemplate: false,
|
||||||
ServerCount: original.ServerCount,
|
ServerCount: original.ServerCount,
|
||||||
|
ServerModel: original.ServerModel,
|
||||||
|
SupportCode: original.SupportCode,
|
||||||
|
Article: original.Article,
|
||||||
PricelistID: original.PricelistID,
|
PricelistID: original.PricelistID,
|
||||||
OnlyInStock: original.OnlyInStock,
|
OnlyInStock: original.OnlyInStock,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
@@ -395,14 +442,14 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
|||||||
|
|
||||||
// GetByUUIDNoAuth returns configuration without ownership check
|
// GetByUUIDNoAuth returns configuration without ownership check
|
||||||
func (s *LocalConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
|
func (s *LocalConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
|
||||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
var localCfg localdb.LocalConfiguration
|
||||||
if err != nil {
|
if err := s.localDB.DB().Preload("CurrentVersion").Where("uuid = ?", uuid).First(&localCfg).Error; err != nil {
|
||||||
return nil, ErrConfigNotFound
|
return nil, ErrConfigNotFound
|
||||||
}
|
}
|
||||||
if !localCfg.IsActive {
|
if !localCfg.IsActive {
|
||||||
return nil, ErrConfigNotFound
|
return nil, ErrConfigNotFound
|
||||||
}
|
}
|
||||||
return localdb.LocalToConfiguration(localCfg), nil
|
return localdb.LocalToConfiguration(&localCfg), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateNoAuth updates configuration without ownership check
|
// UpdateNoAuth updates configuration without ownership check
|
||||||
@@ -414,9 +461,21 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
|||||||
|
|
||||||
projectUUID := localCfg.ProjectUUID
|
projectUUID := localCfg.ProjectUUID
|
||||||
if req.ProjectUUID != nil {
|
if req.ProjectUUID != nil {
|
||||||
|
requestedProjectUUID := strings.TrimSpace(*req.ProjectUUID)
|
||||||
|
currentProjectUUID := ""
|
||||||
|
if localCfg.ProjectUUID != nil {
|
||||||
|
currentProjectUUID = strings.TrimSpace(*localCfg.ProjectUUID)
|
||||||
|
}
|
||||||
|
|
||||||
projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
// Allow save for legacy/orphaned configs when request keeps the same project UUID.
|
||||||
|
// This can happen for imported configs whose project is not present in local cache.
|
||||||
|
if errors.Is(err, ErrProjectNotFound) && requestedProjectUUID != "" && requestedProjectUUID == currentProjectUUID {
|
||||||
|
projectUUID = localCfg.ProjectUUID
|
||||||
|
} else {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||||
@@ -424,6 +483,18 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.ServerModel) != "" {
|
||||||
|
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
|
||||||
|
ServerModel: req.ServerModel,
|
||||||
|
SupportCode: req.SupportCode,
|
||||||
|
ServerPricelist: pricelistID,
|
||||||
|
})
|
||||||
|
if articleErr != nil {
|
||||||
|
return nil, articleErr
|
||||||
|
}
|
||||||
|
req.Article = articleResult.Article
|
||||||
|
}
|
||||||
|
|
||||||
total := req.Items.Total()
|
total := req.Items.Total()
|
||||||
if req.ServerCount > 1 {
|
if req.ServerCount > 1 {
|
||||||
total *= float64(req.ServerCount)
|
total *= float64(req.ServerCount)
|
||||||
@@ -444,6 +515,9 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
|||||||
localCfg.Notes = req.Notes
|
localCfg.Notes = req.Notes
|
||||||
localCfg.IsTemplate = req.IsTemplate
|
localCfg.IsTemplate = req.IsTemplate
|
||||||
localCfg.ServerCount = req.ServerCount
|
localCfg.ServerCount = req.ServerCount
|
||||||
|
localCfg.ServerModel = req.ServerModel
|
||||||
|
localCfg.SupportCode = req.SupportCode
|
||||||
|
localCfg.Article = req.Article
|
||||||
localCfg.PricelistID = pricelistID
|
localCfg.PricelistID = pricelistID
|
||||||
localCfg.OnlyInStock = req.OnlyInStock
|
localCfg.OnlyInStock = req.OnlyInStock
|
||||||
localCfg.UpdatedAt = time.Now()
|
localCfg.UpdatedAt = time.Now()
|
||||||
@@ -509,10 +583,30 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
|
func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
|
||||||
|
return s.CloneNoAuthToProjectFromVersion(configUUID, newName, ownerUsername, projectUUID, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LocalConfigurationService) CloneNoAuthToProjectFromVersion(configUUID string, newName string, ownerUsername string, projectUUID *string, fromVersion int) (*models.Configuration, error) {
|
||||||
original, err := s.GetByUUIDNoAuth(configUUID)
|
original, err := s.GetByUUIDNoAuth(configUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If fromVersion specified, use snapshot from that version
|
||||||
|
if fromVersion > 0 {
|
||||||
|
version, vErr := s.GetVersion(configUUID, fromVersion)
|
||||||
|
if vErr != nil {
|
||||||
|
return nil, vErr
|
||||||
|
}
|
||||||
|
snapshot, decErr := s.decodeConfigurationSnapshot(version.Data)
|
||||||
|
if decErr != nil {
|
||||||
|
return nil, fmt.Errorf("decode version snapshot for clone: %w", decErr)
|
||||||
|
}
|
||||||
|
snapshotCfg := localdb.LocalToConfiguration(snapshot)
|
||||||
|
original = snapshotCfg
|
||||||
|
original.UUID = configUUID // preserve original UUID for project resolution
|
||||||
|
}
|
||||||
|
|
||||||
resolvedProjectUUID := original.ProjectUUID
|
resolvedProjectUUID := original.ProjectUUID
|
||||||
if projectUUID != nil {
|
if projectUUID != nil {
|
||||||
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
||||||
@@ -706,6 +800,58 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateServerCount updates server count and recalculates total price without creating a new version.
|
||||||
|
func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverCount int) (*models.Configuration, error) {
|
||||||
|
if serverCount < 1 {
|
||||||
|
return nil, fmt.Errorf("server count must be at least 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
localCfg, err := s.localDB.GetConfigurationByUUID(configUUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
localCfg.ServerCount = serverCount
|
||||||
|
total := localCfg.Items.Total()
|
||||||
|
if serverCount > 1 {
|
||||||
|
total *= float64(serverCount)
|
||||||
|
}
|
||||||
|
localCfg.TotalPrice = &total
|
||||||
|
localCfg.UpdatedAt = time.Now()
|
||||||
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
|
var cfg *models.Configuration
|
||||||
|
err = s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Save(localCfg).Error; err != nil {
|
||||||
|
return fmt.Errorf("save local configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use existing current version for the pending change
|
||||||
|
var version localdb.LocalConfigurationVersion
|
||||||
|
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
|
||||||
|
if err := tx.Where("id = ?", *localCfg.CurrentVersionID).First(&version).Error; err != nil {
|
||||||
|
return fmt.Errorf("load current version: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
|
||||||
|
Order("version_no DESC").First(&version).Error; err != nil {
|
||||||
|
return fmt.Errorf("load latest version: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg = localdb.LocalToConfiguration(localCfg)
|
||||||
|
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "update", &version, ""); err != nil {
|
||||||
|
return fmt.Errorf("enqueue server-count pending change: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ImportFromServer imports configurations from MariaDB to local SQLite cache.
|
// ImportFromServer imports configurations from MariaDB to local SQLite cache.
|
||||||
func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) {
|
func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) {
|
||||||
return s.syncService.ImportConfigurationsToLocal()
|
return s.syncService.ImportConfigurationsToLocal()
|
||||||
@@ -834,6 +980,7 @@ func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalCon
|
|||||||
return fmt.Errorf("set current version id: %w", err)
|
return fmt.Errorf("set current version id: %w", err)
|
||||||
}
|
}
|
||||||
localCfg.CurrentVersionID = &version.ID
|
localCfg.CurrentVersionID = &version.ID
|
||||||
|
localCfg.CurrentVersion = version
|
||||||
|
|
||||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
|
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
|
||||||
return fmt.Errorf("enqueue create pending change: %w", err)
|
return fmt.Errorf("enqueue create pending change: %w", err)
|
||||||
@@ -860,6 +1007,26 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
|||||||
return fmt.Errorf("lock configuration row: %w", err)
|
return fmt.Errorf("lock configuration row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if operation == "update" {
|
||||||
|
currentVersion, err := s.loadCurrentVersionTx(tx, &locked)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load current version before save: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy/orphaned rows may have empty or stale current_version_id.
|
||||||
|
// In that case we treat update as content-changing and append a fresh version.
|
||||||
|
if currentVersion != nil {
|
||||||
|
sameRevisionContent, err := s.hasSameRevisionContent(localCfg, currentVersion)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("compare revision content: %w", err)
|
||||||
|
}
|
||||||
|
if sameRevisionContent {
|
||||||
|
cfg = localdb.LocalToConfiguration(&locked)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Save(localCfg).Error; err != nil {
|
if err := tx.Save(localCfg).Error; err != nil {
|
||||||
return fmt.Errorf("save local configuration: %w", err)
|
return fmt.Errorf("save local configuration: %w", err)
|
||||||
}
|
}
|
||||||
@@ -875,6 +1042,7 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
|||||||
return fmt.Errorf("update current version id: %w", err)
|
return fmt.Errorf("update current version id: %w", err)
|
||||||
}
|
}
|
||||||
localCfg.CurrentVersionID = &version.ID
|
localCfg.CurrentVersionID = &version.ID
|
||||||
|
localCfg.CurrentVersion = version
|
||||||
|
|
||||||
cfg = localdb.LocalToConfiguration(localCfg)
|
cfg = localdb.LocalToConfiguration(localCfg)
|
||||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil {
|
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil {
|
||||||
@@ -893,6 +1061,44 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) (*localdb.LocalConfigurationVersion, error) {
|
||||||
|
var version localdb.LocalConfigurationVersion
|
||||||
|
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
|
||||||
|
if err := tx.Where("id = ?", *localCfg.CurrentVersionID).First(&version).Error; err == nil {
|
||||||
|
return &version, nil
|
||||||
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
|
||||||
|
Order("version_no DESC").
|
||||||
|
First(&version).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LocalConfigurationService) hasSameRevisionContent(localCfg *localdb.LocalConfiguration, currentVersion *localdb.LocalConfigurationVersion) (bool, error) {
|
||||||
|
currentSnapshotCfg, err := s.decodeConfigurationSnapshot(currentVersion.Data)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("decode current version snapshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentFingerprint, err := localdb.BuildConfigurationSpecPriceFingerprint(currentSnapshotCfg)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("build current snapshot fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
nextFingerprint, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("build next snapshot fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
return currentFingerprint == nextFingerprint, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *LocalConfigurationService) appendVersionTx(
|
func (s *LocalConfigurationService) appendVersionTx(
|
||||||
tx *gorm.DB,
|
tx *gorm.DB,
|
||||||
localCfg *localdb.LocalConfiguration,
|
localCfg *localdb.LocalConfiguration,
|
||||||
|
|||||||
@@ -27,8 +27,12 @@ func TestSaveCreatesNewVersionAndUpdatesCurrentPointer(t *testing.T) {
|
|||||||
t.Fatalf("create config: %v", err)
|
t.Fatalf("create config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := service.RenameNoAuth(created.UUID, "v2"); err != nil {
|
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
t.Fatalf("rename config: %v", err)
|
Name: "v1",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
|
||||||
|
ServerCount: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
versions := loadVersions(t, local, created.UUID)
|
versions := loadVersions(t, local, created.UUID)
|
||||||
@@ -60,8 +64,12 @@ func TestRollbackCreatesNewVersionWithTargetData(t *testing.T) {
|
|||||||
t.Fatalf("create config: %v", err)
|
t.Fatalf("create config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := service.RenameNoAuth(created.UUID, "changed"); err != nil {
|
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
t.Fatalf("rename config: %v", err)
|
Name: "base",
|
||||||
|
Items: models.ConfigItems{{LotName: "RAM_A", Quantity: 3, UnitPrice: 100}},
|
||||||
|
ServerCount: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update config: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := service.RollbackToVersionWithNote(created.UUID, 1, "tester", "test rollback"); err != nil {
|
if _, err := service.RollbackToVersionWithNote(created.UUID, 1, "tester", "test rollback"); err != nil {
|
||||||
t.Fatalf("rollback to v1: %v", err)
|
t.Fatalf("rollback to v1: %v", err)
|
||||||
@@ -79,6 +87,56 @@ func TestRollbackCreatesNewVersionWithTargetData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoAuthSkipsRevisionWhenSpecAndPriceUnchanged(t *testing.T) {
|
||||||
|
service, local := newLocalConfigServiceForTest(t)
|
||||||
|
|
||||||
|
created, err := service.Create("tester", &CreateConfigRequest{
|
||||||
|
Name: "dedupe",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
|
Name: "dedupe",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first update config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
|
Name: "dedupe",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second update config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
versions := loadVersions(t, local, created.UUID)
|
||||||
|
if len(versions) != 2 {
|
||||||
|
t.Fatalf("expected 2 versions (create + first update), got %d", len(versions))
|
||||||
|
}
|
||||||
|
if versions[1].VersionNo != 2 {
|
||||||
|
t.Fatalf("expected latest version_no=2, got %d", versions[1].VersionNo)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingCount int64
|
||||||
|
if err := local.DB().
|
||||||
|
Table("pending_changes").
|
||||||
|
Where("entity_type = ? AND entity_uuid = ?", "configuration", created.UUID).
|
||||||
|
Count(&pendingCount).Error; err != nil {
|
||||||
|
t.Fatalf("count pending changes: %v", err)
|
||||||
|
}
|
||||||
|
if pendingCount != 2 {
|
||||||
|
t.Fatalf("expected 2 pending changes (create + first update), got %d", pendingCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
|
func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
|
||||||
service, local := newLocalConfigServiceForTest(t)
|
service, local := newLocalConfigServiceForTest(t)
|
||||||
|
|
||||||
@@ -97,8 +155,12 @@ func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
|
|||||||
}
|
}
|
||||||
v1Before := versionsBefore[0]
|
v1Before := versionsBefore[0]
|
||||||
|
|
||||||
if _, err := service.RenameNoAuth(created.UUID, "after-rename"); err != nil {
|
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
t.Fatalf("rename config: %v", err)
|
Name: "initial",
|
||||||
|
Items: models.ConfigItems{{LotName: "SSD_A", Quantity: 2, UnitPrice: 300}},
|
||||||
|
ServerCount: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update config: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
||||||
t.Fatalf("rollback: %v", err)
|
t.Fatalf("rollback: %v", err)
|
||||||
@@ -144,7 +206,7 @@ func TestConcurrentSaveNoDuplicateVersionNumbers(t *testing.T) {
|
|||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
<-start
|
<-start
|
||||||
if err := renameWithRetry(service, created.UUID, fmt.Sprintf("name-%d", i)); err != nil {
|
if err := updateWithRetry(service, created.UUID, i+2); err != nil {
|
||||||
errCh <- err
|
errCh <- err
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -191,7 +253,8 @@ func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
|
|||||||
project := &localdb.LocalProject{
|
project := &localdb.LocalProject{
|
||||||
UUID: "project-keep",
|
UUID: "project-keep",
|
||||||
OwnerUsername: "tester",
|
OwnerUsername: "tester",
|
||||||
Name: "Keep Project",
|
Code: "TEST-KEEP",
|
||||||
|
Name: ptrString("Keep Project"),
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
@@ -227,6 +290,103 @@ func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoAuthAllowsOrphanProjectWhenUUIDUnchanged(t *testing.T) {
|
||||||
|
service, local := newLocalConfigServiceForTest(t)
|
||||||
|
|
||||||
|
project := &localdb.LocalProject{
|
||||||
|
UUID: "project-orphan",
|
||||||
|
OwnerUsername: "tester",
|
||||||
|
Code: "TEST-ORPHAN",
|
||||||
|
Name: ptrString("Orphan Project"),
|
||||||
|
IsActive: true,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
SyncStatus: "synced",
|
||||||
|
}
|
||||||
|
if err := local.SaveProject(project); err != nil {
|
||||||
|
t.Fatalf("save project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := service.Create("tester", &CreateConfigRequest{
|
||||||
|
Name: "cfg",
|
||||||
|
ProjectUUID: &project.UUID,
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate missing project in local cache while config still references its UUID.
|
||||||
|
if err := local.DB().Where("uuid = ?", project.UUID).Delete(&localdb.LocalProject{}).Error; err != nil {
|
||||||
|
t.Fatalf("delete project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
|
Name: "cfg-updated",
|
||||||
|
ProjectUUID: &project.UUID,
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update config with orphan project_uuid: %v", err)
|
||||||
|
}
|
||||||
|
if updated.ProjectUUID == nil || *updated.ProjectUUID != project.UUID {
|
||||||
|
t.Fatalf("expected project_uuid to stay %s after update, got %+v", project.UUID, updated.ProjectUUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoAuthRecoversWhenCurrentVersionMissing(t *testing.T) {
|
||||||
|
service, local := newLocalConfigServiceForTest(t)
|
||||||
|
|
||||||
|
created, err := service.Create("tester", &CreateConfigRequest{
|
||||||
|
Name: "cfg",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate corrupted/legacy versioning state:
|
||||||
|
// local configuration exists, but all version rows are gone and pointer is stale.
|
||||||
|
if err := local.DB().Where("configuration_uuid = ?", created.UUID).
|
||||||
|
Delete(&localdb.LocalConfigurationVersion{}).Error; err != nil {
|
||||||
|
t.Fatalf("delete versions: %v", err)
|
||||||
|
}
|
||||||
|
staleID := "missing-version-id"
|
||||||
|
if err := local.DB().Model(&localdb.LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", created.UUID).
|
||||||
|
Update("current_version_id", staleID).Error; err != nil {
|
||||||
|
t.Fatalf("set stale current_version_id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
|
Name: "cfg-updated",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update config with missing current version: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if updated.Name != "cfg-updated" {
|
||||||
|
t.Fatalf("expected updated name, got %q", updated.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
versions := loadVersions(t, local, created.UUID)
|
||||||
|
if len(versions) != 1 {
|
||||||
|
t.Fatalf("expected 1 recreated version, got %d", len(versions))
|
||||||
|
}
|
||||||
|
if versions[0].VersionNo != 1 {
|
||||||
|
t.Fatalf("expected recreated version_no=1, got %d", versions[0].VersionNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrString(value string) *string {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
|
func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -259,10 +419,14 @@ func loadVersions(t *testing.T, local *localdb.LocalDB, configurationUUID string
|
|||||||
return versions
|
return versions
|
||||||
}
|
}
|
||||||
|
|
||||||
func renameWithRetry(service *LocalConfigurationService, uuid string, name string) error {
|
func updateWithRetry(service *LocalConfigurationService, uuid string, quantity int) error {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for i := 0; i < 6; i++ {
|
for i := 0; i < 6; i++ {
|
||||||
_, err := service.RenameNoAuth(uuid, name)
|
_, err := service.UpdateNoAuth(uuid, &CreateConfigRequest{
|
||||||
|
Name: "base",
|
||||||
|
Items: models.ConfigItems{{LotName: "NIC_A", Quantity: quantity, UnitPrice: 150}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -273,7 +437,7 @@ func renameWithRetry(service *LocalConfigurationService, uuid string, name strin
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return fmt.Errorf("rename retries exhausted: %w", lastErr)
|
return fmt.Errorf("update retries exhausted: %w", lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRollbackVersionSnapshotJSONMatchesV1(t *testing.T) {
|
func TestRollbackVersionSnapshotJSONMatchesV1(t *testing.T) {
|
||||||
@@ -287,8 +451,12 @@ func TestRollbackVersionSnapshotJSONMatchesV1(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create config: %v", err)
|
t.Fatalf("create config: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := service.RenameNoAuth(created.UUID, "second"); err != nil {
|
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
t.Fatalf("rename: %v", err)
|
Name: "initial",
|
||||||
|
Items: models.ConfigItems{{LotName: "GPU_A", Quantity: 2, UnitPrice: 2000}},
|
||||||
|
ServerCount: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
||||||
t.Fatalf("rollback: %v", err)
|
t.Fatalf("rollback: %v", err)
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrProjectNotFound = errors.New("project not found")
|
ErrProjectNotFound = errors.New("project not found")
|
||||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||||
|
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||||
|
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProjectService struct {
|
type ProjectService struct {
|
||||||
@@ -29,12 +31,16 @@ func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreateProjectRequest struct {
|
type CreateProjectRequest struct {
|
||||||
Name string `json:"name"`
|
Code string `json:"code"`
|
||||||
|
Variant string `json:"variant,omitempty"`
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
TrackerURL string `json:"tracker_url"`
|
TrackerURL string `json:"tracker_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateProjectRequest struct {
|
type UpdateProjectRequest struct {
|
||||||
Name string `json:"name"`
|
Code *string `json:"code,omitempty"`
|
||||||
|
Variant *string `json:"variant,omitempty"`
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
TrackerURL *string `json:"tracker_url,omitempty"`
|
TrackerURL *string `json:"tracker_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,17 +51,30 @@ type ProjectConfigurationsResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) (*models.Project, error) {
|
func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) (*models.Project, error) {
|
||||||
name := strings.TrimSpace(req.Name)
|
var namePtr *string
|
||||||
if name == "" {
|
if req.Name != nil {
|
||||||
return nil, fmt.Errorf("project name is required")
|
name := strings.TrimSpace(*req.Name)
|
||||||
|
if name != "" {
|
||||||
|
namePtr = &name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
code := strings.TrimSpace(req.Code)
|
||||||
|
if code == "" {
|
||||||
|
return nil, fmt.Errorf("project code is required")
|
||||||
|
}
|
||||||
|
variant := strings.TrimSpace(req.Variant)
|
||||||
|
if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
localProject := &localdb.LocalProject{
|
localProject := &localdb.LocalProject{
|
||||||
UUID: uuid.NewString(),
|
UUID: uuid.NewString(),
|
||||||
OwnerUsername: ownerUsername,
|
OwnerUsername: ownerUsername,
|
||||||
Name: name,
|
Code: code,
|
||||||
TrackerURL: normalizeProjectTrackerURL(name, req.TrackerURL),
|
Variant: variant,
|
||||||
|
Name: namePtr,
|
||||||
|
TrackerURL: normalizeProjectTrackerURL(code, req.TrackerURL),
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsSystem: false,
|
IsSystem: false,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
@@ -76,20 +95,33 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ErrProjectNotFound
|
return nil, ErrProjectNotFound
|
||||||
}
|
}
|
||||||
if localProject.OwnerUsername != ownerUsername {
|
|
||||||
return nil, ErrProjectForbidden
|
if req.Code != nil {
|
||||||
|
code := strings.TrimSpace(*req.Code)
|
||||||
|
if code == "" {
|
||||||
|
return nil, fmt.Errorf("project code is required")
|
||||||
|
}
|
||||||
|
localProject.Code = code
|
||||||
|
}
|
||||||
|
if req.Variant != nil {
|
||||||
|
localProject.Variant = strings.TrimSpace(*req.Variant)
|
||||||
|
}
|
||||||
|
if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
name := strings.TrimSpace(req.Name)
|
if req.Name != nil {
|
||||||
if name == "" {
|
name := strings.TrimSpace(*req.Name)
|
||||||
return nil, fmt.Errorf("project name is required")
|
if name == "" {
|
||||||
|
localProject.Name = nil
|
||||||
|
} else {
|
||||||
|
localProject.Name = &name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
localProject.Name = name
|
|
||||||
if req.TrackerURL != nil {
|
if req.TrackerURL != nil {
|
||||||
localProject.TrackerURL = normalizeProjectTrackerURL(name, *req.TrackerURL)
|
localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, *req.TrackerURL)
|
||||||
} else if strings.TrimSpace(localProject.TrackerURL) == "" {
|
} else if strings.TrimSpace(localProject.TrackerURL) == "" {
|
||||||
localProject.TrackerURL = normalizeProjectTrackerURL(name, "")
|
localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, "")
|
||||||
}
|
}
|
||||||
localProject.UpdatedAt = time.Now()
|
localProject.UpdatedAt = time.Now()
|
||||||
localProject.SyncStatus = "pending"
|
localProject.SyncStatus = "pending"
|
||||||
@@ -102,6 +134,38 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
|||||||
return localdb.LocalToProject(localProject), nil
|
return localdb.LocalToProject(localProject), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ProjectService) ensureUniqueProjectCodeVariant(excludeUUID, code, variant string) error {
|
||||||
|
normalizedCode := normalizeProjectCode(code)
|
||||||
|
normalizedVariant := normalizeProjectVariant(variant)
|
||||||
|
if normalizedCode == "" {
|
||||||
|
return fmt.Errorf("project code is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
projects, err := s.localDB.GetAllProjects(true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range projects {
|
||||||
|
project := projects[i]
|
||||||
|
if excludeUUID != "" && project.UUID == excludeUUID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if normalizeProjectCode(project.Code) == normalizedCode &&
|
||||||
|
normalizeProjectVariant(project.Variant) == normalizedVariant {
|
||||||
|
return ErrProjectCodeExists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeProjectCode(code string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(code))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeProjectVariant(variant string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(variant))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
|
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
|
||||||
return s.setProjectActive(projectUUID, ownerUsername, false)
|
return s.setProjectActive(projectUUID, ownerUsername, false)
|
||||||
}
|
}
|
||||||
@@ -110,15 +174,23 @@ func (s *ProjectService) Reactivate(projectUUID, ownerUsername string) error {
|
|||||||
return s.setProjectActive(projectUUID, ownerUsername, true)
|
return s.setProjectActive(projectUUID, ownerUsername, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ProjectService) DeleteVariant(projectUUID, ownerUsername string) error {
|
||||||
|
localProject, err := s.localDB.GetProjectByUUID(projectUUID)
|
||||||
|
if err != nil {
|
||||||
|
return ErrProjectNotFound
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(localProject.Variant) == "" {
|
||||||
|
return ErrCannotDeleteMainVariant
|
||||||
|
}
|
||||||
|
return s.setProjectActive(projectUUID, ownerUsername, false)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ProjectService) setProjectActive(projectUUID, ownerUsername string, isActive bool) error {
|
func (s *ProjectService) setProjectActive(projectUUID, ownerUsername string, isActive bool) error {
|
||||||
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||||
var project localdb.LocalProject
|
var project localdb.LocalProject
|
||||||
if err := tx.Where("uuid = ?", projectUUID).First(&project).Error; err != nil {
|
if err := tx.Where("uuid = ?", projectUUID).First(&project).Error; err != nil {
|
||||||
return ErrProjectNotFound
|
return ErrProjectNotFound
|
||||||
}
|
}
|
||||||
if project.OwnerUsername != ownerUsername {
|
|
||||||
return ErrProjectForbidden
|
|
||||||
}
|
|
||||||
if project.IsActive == isActive {
|
if project.IsActive == isActive {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -203,8 +275,8 @@ func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status s
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
localConfigs, err := s.localDB.GetConfigurations()
|
var localConfigs []localdb.LocalConfiguration
|
||||||
if err != nil {
|
if err := s.localDB.DB().Preload("CurrentVersion").Order("created_at DESC").Find(&localConfigs).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
existing.OwnerUsername = project.OwnerUsername
|
existing.OwnerUsername = project.OwnerUsername
|
||||||
|
existing.Code = project.Code
|
||||||
existing.Name = project.Name
|
existing.Name = project.Name
|
||||||
existing.TrackerURL = project.TrackerURL
|
existing.TrackerURL = project.TrackerURL
|
||||||
existing.IsActive = project.IsActive
|
existing.IsActive = project.IsActive
|
||||||
@@ -388,6 +389,9 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
slog.Info("deleted stale local pricelists", "deleted", removed)
|
slog.Info("deleted stale local pricelists", "deleted", removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backfill lot_category for used pricelists (older local caches may miss the column values).
|
||||||
|
s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
|
||||||
|
|
||||||
// Update last sync time
|
// Update last sync time
|
||||||
s.localDB.SetLastSyncTime(time.Now())
|
s.localDB.SetLastSyncTime(time.Now())
|
||||||
s.RecordSyncHeartbeat()
|
s.RecordSyncHeartbeat()
|
||||||
@@ -396,6 +400,83 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
return synced, nil
|
return synced, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.PricelistRepository, activeServerPricelistIDs []uint) {
|
||||||
|
if s.localDB == nil || pricelistRepo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeSet := make(map[uint]struct{}, len(activeServerPricelistIDs))
|
||||||
|
for _, id := range activeServerPricelistIDs {
|
||||||
|
activeSet[id] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
ID uint `gorm:"column:id"`
|
||||||
|
}
|
||||||
|
var usedRows []row
|
||||||
|
if err := s.localDB.DB().Raw(`
|
||||||
|
SELECT DISTINCT pricelist_id AS id
|
||||||
|
FROM local_configurations
|
||||||
|
WHERE is_active = 1 AND pricelist_id IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT warehouse_pricelist_id AS id
|
||||||
|
FROM local_configurations
|
||||||
|
WHERE is_active = 1 AND warehouse_pricelist_id IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT competitor_pricelist_id AS id
|
||||||
|
FROM local_configurations
|
||||||
|
WHERE is_active = 1 AND competitor_pricelist_id IS NOT NULL
|
||||||
|
`).Scan(&usedRows).Error; err != nil {
|
||||||
|
slog.Warn("pricelist category backfill: failed to list used pricelists", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range usedRows {
|
||||||
|
serverID := r.ID
|
||||||
|
if serverID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := activeSet[serverID]; !ok {
|
||||||
|
// Not present on server (or not active) - cannot backfill from remote.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
localPL, err := s.localDB.GetLocalPricelistByServerID(serverID)
|
||||||
|
if err != nil || localPL == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.localDB.CountLocalPricelistItems(localPL.ID) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
missing, err := s.localDB.CountLocalPricelistItemsWithEmptyCategory(localPL.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("pricelist category backfill: failed to check local items", "server_id", serverID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if missing == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
serverItems, _, err := pricelistRepo.GetItems(serverID, 0, 10000, "")
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("pricelist category backfill: failed to load server items", "server_id", serverID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
||||||
|
for i := range serverItems {
|
||||||
|
localItems[i] = *localdb.PricelistItemToLocal(&serverItems[i], localPL.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.localDB.ReplaceLocalPricelistItems(localPL.ID, localItems); err != nil {
|
||||||
|
slog.Warn("pricelist category backfill: failed to replace local items", "server_id", serverID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slog.Info("pricelist category backfill: refreshed local items", "server_id", serverID, "items", len(localItems))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RecordSyncHeartbeat updates shared sync heartbeat for current DB user.
|
// RecordSyncHeartbeat updates shared sync heartbeat for current DB user.
|
||||||
// Only users with write rights are expected to be able to update this table.
|
// Only users with write rights are expected to be able to update this table.
|
||||||
func (s *Service) RecordSyncHeartbeat() {
|
func (s *Service) RecordSyncHeartbeat() {
|
||||||
@@ -595,15 +676,7 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
|||||||
// Convert and save locally
|
// Convert and save locally
|
||||||
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
||||||
for i, item := range serverItems {
|
for i, item := range serverItems {
|
||||||
partnumbers := make(localdb.LocalStringList, 0, len(item.Partnumbers))
|
localItems[i] = *localdb.PricelistItemToLocal(&item, localPricelistID)
|
||||||
partnumbers = append(partnumbers, item.Partnumbers...)
|
|
||||||
localItems[i] = localdb.LocalPricelistItem{
|
|
||||||
PricelistID: localPricelistID,
|
|
||||||
LotName: item.LotName,
|
|
||||||
Price: item.Price,
|
|
||||||
AvailableQty: item.AvailableQty,
|
|
||||||
Partnumbers: partnumbers,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
||||||
@@ -776,11 +849,36 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
|||||||
projectRepo := repository.NewProjectRepository(mariaDB)
|
projectRepo := repository.NewProjectRepository(mariaDB)
|
||||||
project := payload.Snapshot
|
project := payload.Snapshot
|
||||||
project.UUID = payload.ProjectUUID
|
project.UUID = payload.ProjectUUID
|
||||||
|
if strings.TrimSpace(project.Code) == "" {
|
||||||
if err := projectRepo.UpsertByUUID(&project); err != nil {
|
project.Code = strings.TrimSpace(derefString(project.Name))
|
||||||
return fmt.Errorf("upsert project on server: %w", err)
|
if project.Code == "" {
|
||||||
|
project.Code = project.UUID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try upsert by UUID first
|
||||||
|
err = projectRepo.UpsertByUUID(&project)
|
||||||
|
if err != nil {
|
||||||
|
// Check if it's a duplicate (code, variant) constraint violation
|
||||||
|
// In this case, find existing project with same (code, variant) and link to it
|
||||||
|
var existing models.Project
|
||||||
|
lookupErr := mariaDB.Where("code = ? AND variant = ?", project.Code, project.Variant).First(&existing).Error
|
||||||
|
if lookupErr == nil {
|
||||||
|
// Found duplicate - link local project to existing server project
|
||||||
|
slog.Info("project duplicate found, linking to existing",
|
||||||
|
"local_uuid", project.UUID,
|
||||||
|
"server_uuid", existing.UUID,
|
||||||
|
"server_id", existing.ID,
|
||||||
|
"code", project.Code,
|
||||||
|
"variant", project.Variant)
|
||||||
|
project.ID = existing.ID
|
||||||
|
} else {
|
||||||
|
// Not a duplicate issue, return original error
|
||||||
|
return fmt.Errorf("upsert project on server: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local project with server ID
|
||||||
localProject, localErr := s.localDB.GetProjectByUUID(project.UUID)
|
localProject, localErr := s.localDB.GetProjectByUUID(project.UUID)
|
||||||
if localErr == nil {
|
if localErr == nil {
|
||||||
if project.ID > 0 {
|
if project.ID > 0 {
|
||||||
@@ -796,6 +894,17 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func derefString(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrString(value string) *string {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
func decodeProjectChangePayload(change *localdb.PendingChange) (ProjectChangePayload, error) {
|
func decodeProjectChangePayload(change *localdb.PendingChange) (ProjectChangePayload, error) {
|
||||||
var payload ProjectChangePayload
|
var payload ProjectChangePayload
|
||||||
if err := json.Unmarshal([]byte(change.Payload), &payload); err == nil && payload.ProjectUUID != "" {
|
if err := json.Unmarshal([]byte(change.Payload), &payload); err == nil && payload.ProjectUUID != "" {
|
||||||
@@ -1066,7 +1175,8 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
|
|||||||
systemProject = &models.Project{
|
systemProject = &models.Project{
|
||||||
UUID: uuid.NewString(),
|
UUID: uuid.NewString(),
|
||||||
OwnerUsername: "",
|
OwnerUsername: "",
|
||||||
Name: "Без проекта",
|
Code: "Без проекта",
|
||||||
|
Name: ptrString("Без проекта"),
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsSystem: true,
|
IsSystem: true,
|
||||||
}
|
}
|
||||||
@@ -1230,6 +1340,21 @@ func (s *Service) loadCurrentConfigurationState(configurationUUID string) (model
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if currentVersionNo == 0 {
|
||||||
|
if err := s.repairMissingConfigurationVersion(localCfg); err != nil {
|
||||||
|
return models.Configuration{}, "", 0, fmt.Errorf("repair missing configuration version: %w", err)
|
||||||
|
}
|
||||||
|
var latest localdb.LocalConfigurationVersion
|
||||||
|
err = s.localDB.DB().
|
||||||
|
Where("configuration_uuid = ?", configurationUUID).
|
||||||
|
Order("version_no DESC").
|
||||||
|
First(&latest).Error
|
||||||
|
if err == nil {
|
||||||
|
currentVersionNo = latest.VersionNo
|
||||||
|
currentVersionID = latest.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if currentVersionNo == 0 {
|
if currentVersionNo == 0 {
|
||||||
return models.Configuration{}, "", 0, fmt.Errorf("no local configuration version found for %s", configurationUUID)
|
return models.Configuration{}, "", 0, fmt.Errorf("no local configuration version found for %s", configurationUUID)
|
||||||
}
|
}
|
||||||
@@ -1237,6 +1362,64 @@ func (s *Service) loadCurrentConfigurationState(configurationUUID string) (model
|
|||||||
return cfg, currentVersionID, currentVersionNo, nil
|
return cfg, currentVersionID, currentVersionNo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) repairMissingConfigurationVersion(localCfg *localdb.LocalConfiguration) error {
|
||||||
|
if localCfg == nil {
|
||||||
|
return fmt.Errorf("local configuration is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||||
|
var cfg localdb.LocalConfiguration
|
||||||
|
if err := tx.Where("uuid = ?", localCfg.UUID).First(&cfg).Error; err != nil {
|
||||||
|
return fmt.Errorf("load local configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If versions exist, just make sure current_version_id is set.
|
||||||
|
var latest localdb.LocalConfigurationVersion
|
||||||
|
if err := tx.Where("configuration_uuid = ?", cfg.UUID).
|
||||||
|
Order("version_no DESC").
|
||||||
|
First(&latest).Error; err == nil {
|
||||||
|
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
|
||||||
|
if err := tx.Model(&localdb.LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", cfg.UUID).
|
||||||
|
Update("current_version_id", latest.ID).Error; err != nil {
|
||||||
|
return fmt.Errorf("set current version id: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fmt.Errorf("load latest version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, err := localdb.BuildConfigurationSnapshot(&cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build configuration snapshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
note := "Auto-repaired missing local version"
|
||||||
|
version := localdb.LocalConfigurationVersion{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
ConfigurationUUID: cfg.UUID,
|
||||||
|
VersionNo: 1,
|
||||||
|
Data: snapshot,
|
||||||
|
ChangeNote: ¬e,
|
||||||
|
AppVersion: appmeta.Version(),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Create(&version).Error; err != nil {
|
||||||
|
return fmt.Errorf("create initial version: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Model(&localdb.LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", cfg.UUID).
|
||||||
|
Update("current_version_id", version.ID).Error; err != nil {
|
||||||
|
return fmt.Errorf("set current version id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Warn("repaired missing local configuration version", "uuid", cfg.UUID, "version_no", version.VersionNo)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: prepared for future conflict resolution:
|
// NOTE: prepared for future conflict resolution:
|
||||||
// when server starts storing version metadata, we can compare payload.CurrentVersionNo
|
// when server starts storing version metadata, we can compare payload.CurrentVersionNo
|
||||||
// against remote version and branch into custom strategies. For now use last-write-wins.
|
// against remote version and branch into custom strategies. For now use last-write-wins.
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package sync_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T) {
|
||||||
|
local := newLocalDBForSyncTest(t)
|
||||||
|
serverDB := newServerDBForSyncTest(t)
|
||||||
|
|
||||||
|
if err := serverDB.AutoMigrate(
|
||||||
|
&models.Pricelist{},
|
||||||
|
&models.PricelistItem{},
|
||||||
|
&models.Lot{},
|
||||||
|
&models.LotPartnumber{},
|
||||||
|
&models.StockLog{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("migrate server tables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverPL := models.Pricelist{
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "2026-02-11-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",
|
||||||
|
LotCategory: "CPU",
|
||||||
|
Price: 10,
|
||||||
|
PriceMethod: "",
|
||||||
|
MetaPrices: "",
|
||||||
|
ManualPrice: nil,
|
||||||
|
AvailableQty: nil,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create server pricelist item: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||||
|
ServerID: serverPL.ID,
|
||||||
|
Source: serverPL.Source,
|
||||||
|
Version: serverPL.Version,
|
||||||
|
Name: serverPL.Notification,
|
||||||
|
CreatedAt: serverPL.CreatedAt,
|
||||||
|
SyncedAt: time.Now(),
|
||||||
|
IsUsed: false,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed local pricelist: %v", err)
|
||||||
|
}
|
||||||
|
localPL, err := local.GetLocalPricelistByServerID(serverPL.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get local pricelist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||||
|
{
|
||||||
|
PricelistID: localPL.ID,
|
||||||
|
LotName: "CPU_A",
|
||||||
|
LotCategory: "",
|
||||||
|
Price: 10,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed local pricelist items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.SaveConfiguration(&localdb.LocalConfiguration{
|
||||||
|
UUID: "cfg-1",
|
||||||
|
OriginalUsername: "tester",
|
||||||
|
Name: "cfg",
|
||||||
|
Items: localdb.LocalConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 10}},
|
||||||
|
IsActive: true,
|
||||||
|
PricelistID: &serverPL.ID,
|
||||||
|
SyncStatus: "synced",
|
||||||
|
CreatedAt: time.Now().Add(-30 * time.Minute),
|
||||||
|
UpdatedAt: time.Now().Add(-30 * time.Minute),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed local configuration with pricelist ref: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := syncsvc.NewServiceWithDB(serverDB, local)
|
||||||
|
if _, err := svc.SyncPricelists(); err != nil {
|
||||||
|
t.Fatalf("sync pricelists: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := local.GetLocalPricelistItems(localPL.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load local items: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("expected 1 local item, got %d", len(items))
|
||||||
|
}
|
||||||
|
if items[0].LotCategory != "CPU" {
|
||||||
|
t.Fatalf("expected lot_category backfilled to CPU, got %q", items[0].LotCategory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) {
|
|||||||
projectService := services.NewProjectService(local)
|
projectService := services.NewProjectService(local)
|
||||||
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
||||||
|
|
||||||
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: "Project A"})
|
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project A"), Code: "PRJ-A"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create project: %v", err)
|
t.Fatalf("create project: %v", err)
|
||||||
}
|
}
|
||||||
@@ -74,11 +74,11 @@ func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T)
|
|||||||
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
||||||
pushService := syncsvc.NewServiceWithDB(serverDB, local)
|
pushService := syncsvc.NewServiceWithDB(serverDB, local)
|
||||||
|
|
||||||
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: "Project v1"})
|
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project v1"), Code: "PRJ-V1"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create project: %v", err)
|
t.Fatalf("create project: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: "Project v2"}); err != nil {
|
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: ptrString("Project v2")}); err != nil {
|
||||||
t.Fatalf("update project: %v", err)
|
t.Fatalf("update project: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,8 +100,8 @@ func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T)
|
|||||||
if err := serverDB.Where("uuid = ?", project.UUID).First(&serverProject).Error; err != nil {
|
if err := serverDB.Where("uuid = ?", project.UUID).First(&serverProject).Error; err != nil {
|
||||||
t.Fatalf("project not pushed to server: %v", err)
|
t.Fatalf("project not pushed to server: %v", err)
|
||||||
}
|
}
|
||||||
if serverProject.Name != "Project v2" {
|
if serverProject.Name == nil || *serverProject.Name != "Project v2" {
|
||||||
t.Fatalf("expected latest project name, got %q", serverProject.Name)
|
t.Fatalf("expected latest project name, got %v", serverProject.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
var serverCfg models.Configuration
|
var serverCfg models.Configuration
|
||||||
@@ -324,6 +324,8 @@ CREATE TABLE qt_projects (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
uuid TEXT NOT NULL UNIQUE,
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
owner_username TEXT NOT NULL,
|
owner_username TEXT NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
variant TEXT NOT NULL DEFAULT '',
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
tracker_url TEXT NULL,
|
tracker_url TEXT NULL,
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
@@ -333,6 +335,9 @@ CREATE TABLE qt_projects (
|
|||||||
);`).Error; err != nil {
|
);`).Error; err != nil {
|
||||||
t.Fatalf("create qt_projects: %v", err)
|
t.Fatalf("create qt_projects: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := db.Exec(`CREATE UNIQUE INDEX idx_qt_projects_code_variant ON qt_projects(code, variant);`).Error; err != nil {
|
||||||
|
t.Fatalf("create qt_projects index: %v", err)
|
||||||
|
}
|
||||||
if err := db.Exec(`
|
if err := db.Exec(`
|
||||||
CREATE TABLE qt_configurations (
|
CREATE TABLE qt_configurations (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -348,6 +353,9 @@ CREATE TABLE qt_configurations (
|
|||||||
notes TEXT NULL,
|
notes TEXT NULL,
|
||||||
is_template INTEGER NOT NULL DEFAULT 0,
|
is_template INTEGER NOT NULL DEFAULT 0,
|
||||||
server_count INTEGER NOT NULL DEFAULT 1,
|
server_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
server_model TEXT NULL,
|
||||||
|
support_code TEXT NULL,
|
||||||
|
article TEXT NULL,
|
||||||
pricelist_id INTEGER NULL,
|
pricelist_id INTEGER NULL,
|
||||||
warehouse_pricelist_id INTEGER NULL,
|
warehouse_pricelist_id INTEGER NULL,
|
||||||
competitor_pricelist_id INTEGER NULL,
|
competitor_pricelist_id INTEGER NULL,
|
||||||
@@ -361,6 +369,10 @@ CREATE TABLE qt_configurations (
|
|||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ptrString(value string) *string {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
func getCurrentVersionInfo(t *testing.T, local *localdb.LocalDB, configurationUUID string, currentVersionID *string) (int, string) {
|
func getCurrentVersionInfo(t *testing.T, local *localdb.LocalDB, configurationUUID string, currentVersionID *string) (int, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if currentVersionID == nil || *currentVersionID == "" {
|
if currentVersionID == nil || *currentVersionID == "" {
|
||||||
|
|||||||
413
man/backup.md
Normal file
413
man/backup.md
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
# AI Implementation Guide: Go Scheduled Backup Rotation (ZIP)
|
||||||
|
|
||||||
|
This document is written **for an AI** to replicate the same backup approach in another Go project. It contains the exact requirements, design notes, and full module listings you can copy.
|
||||||
|
|
||||||
|
## Requirements (Behavioral)
|
||||||
|
- Run backups on a daily schedule at a configured local time (default `00:00`).
|
||||||
|
- At startup, if there is no backup for the current period, create it immediately.
|
||||||
|
- Backup content must include:
|
||||||
|
- Local SQLite DB file (e.g., `qfs.db`).
|
||||||
|
- SQLite sidecars (`-wal`, `-shm`) if present.
|
||||||
|
- Runtime config file (e.g., `config.yaml`) if present.
|
||||||
|
- Backups must be ZIP archives named:
|
||||||
|
- `qfs-backp-YYYY-MM-DD.zip`
|
||||||
|
- Retention policy:
|
||||||
|
- 7 daily, 4 weekly, 12 monthly, 10 yearly archives.
|
||||||
|
- Keep backups in period-specific directories:
|
||||||
|
- `<backup root>/daily`, `/weekly`, `/monthly`, `/yearly`.
|
||||||
|
- Prevent duplicate backups for the same period via a marker file.
|
||||||
|
- Log success with the archive path, and log errors on failure.
|
||||||
|
|
||||||
|
## Configuration & Env
|
||||||
|
- Config key: `backup.time` with format `HH:MM` in local time. Default: `00:00`.
|
||||||
|
- Env overrides:
|
||||||
|
- `QFS_BACKUP_DIR` — backup root directory.
|
||||||
|
- `QFS_BACKUP_DISABLE` — disable backups (`1/true/yes`).
|
||||||
|
|
||||||
|
## Integration Steps (Minimal)
|
||||||
|
1. Add `BackupConfig` to your config struct.
|
||||||
|
2. Add a scheduler goroutine that:
|
||||||
|
- On startup: runs backup immediately if needed.
|
||||||
|
- Then sleeps until next configured time and runs daily.
|
||||||
|
3. Add the backup module (below).
|
||||||
|
4. Wire logs for success/failure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Full Go Listings
|
||||||
|
|
||||||
|
## 1) Backup Module (Drop-in)
|
||||||
|
Create: `internal/appstate/backup.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
package appstate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type backupPeriod struct {
|
||||||
|
name string
|
||||||
|
retention int
|
||||||
|
key func(time.Time) string
|
||||||
|
date func(time.Time) string
|
||||||
|
}
|
||||||
|
|
||||||
|
var backupPeriods = []backupPeriod{
|
||||||
|
{
|
||||||
|
name: "daily",
|
||||||
|
retention: 7,
|
||||||
|
key: func(t time.Time) string {
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
},
|
||||||
|
date: func(t time.Time) string {
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "weekly",
|
||||||
|
retention: 4,
|
||||||
|
key: func(t time.Time) string {
|
||||||
|
y, w := t.ISOWeek()
|
||||||
|
return fmt.Sprintf("%04d-W%02d", y, w)
|
||||||
|
},
|
||||||
|
date: func(t time.Time) string {
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "monthly",
|
||||||
|
retention: 12,
|
||||||
|
key: func(t time.Time) string {
|
||||||
|
return t.Format("2006-01")
|
||||||
|
},
|
||||||
|
date: func(t time.Time) string {
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "yearly",
|
||||||
|
retention: 10,
|
||||||
|
key: func(t time.Time) string {
|
||||||
|
return t.Format("2006")
|
||||||
|
},
|
||||||
|
date: func(t time.Time) string {
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
envBackupDisable = "QFS_BACKUP_DISABLE"
|
||||||
|
envBackupDir = "QFS_BACKUP_DIR"
|
||||||
|
)
|
||||||
|
|
||||||
|
var backupNow = time.Now
|
||||||
|
|
||||||
|
// EnsureRotatingLocalBackup creates or refreshes daily/weekly/monthly/yearly backups
|
||||||
|
// for the local database and config. It keeps a limited number per period.
|
||||||
|
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
|
||||||
|
if isBackupDisabled() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if dbPath == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(dbPath); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("stat db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
root := resolveBackupRoot(dbPath)
|
||||||
|
now := backupNow()
|
||||||
|
|
||||||
|
created := make([]string, 0)
|
||||||
|
for _, period := range backupPeriods {
|
||||||
|
newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath)
|
||||||
|
if err != nil {
|
||||||
|
return created, err
|
||||||
|
}
|
||||||
|
if len(newFiles) > 0 {
|
||||||
|
created = append(created, newFiles...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveBackupRoot(dbPath string) string {
|
||||||
|
if fromEnv := strings.TrimSpace(os.Getenv(envBackupDir)); fromEnv != "" {
|
||||||
|
return filepath.Clean(fromEnv)
|
||||||
|
}
|
||||||
|
return filepath.Join(filepath.Dir(dbPath), "backups")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBackupDisabled() bool {
|
||||||
|
val := strings.ToLower(strings.TrimSpace(os.Getenv(envBackupDisable)))
|
||||||
|
return val == "1" || val == "true" || val == "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensurePeriodBackup(root string, period backupPeriod, now time.Time, dbPath, configPath string) ([]string, error) {
|
||||||
|
key := period.key(now)
|
||||||
|
periodDir := filepath.Join(root, period.name)
|
||||||
|
if err := os.MkdirAll(periodDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create %s backup dir: %w", period.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasBackupForKey(periodDir, key) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveName := fmt.Sprintf("qfs-backp-%s.zip", period.date(now))
|
||||||
|
archivePath := filepath.Join(periodDir, archiveName)
|
||||||
|
|
||||||
|
if err := createBackupArchive(archivePath, dbPath, configPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("create %s backup archive: %w", period.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writePeriodMarker(periodDir, key); err != nil {
|
||||||
|
return []string{archivePath}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pruneOldBackups(periodDir, period.retention); err != nil {
|
||||||
|
return []string{archivePath}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return []string{archivePath}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasBackupForKey(periodDir, key string) bool {
|
||||||
|
marker := periodMarker{Key: ""}
|
||||||
|
data, err := os.ReadFile(periodMarkerPath(periodDir))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &marker); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return marker.Key == key
|
||||||
|
}
|
||||||
|
|
||||||
|
type periodMarker struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func periodMarkerPath(periodDir string) string {
|
||||||
|
return filepath.Join(periodDir, ".period.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePeriodMarker(periodDir, key string) error {
|
||||||
|
data, err := json.MarshalIndent(periodMarker{Key: key}, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(periodMarkerPath(periodDir), data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pruneOldBackups(periodDir string, keep int) error {
|
||||||
|
entries, err := os.ReadDir(periodDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read backups dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files := make([]os.DirEntry, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(entry.Name(), ".zip") {
|
||||||
|
files = append(files, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) <= keep {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(files, func(i, j int) bool {
|
||||||
|
infoI, errI := files[i].Info()
|
||||||
|
infoJ, errJ := files[j].Info()
|
||||||
|
if errI != nil || errJ != nil {
|
||||||
|
return files[i].Name() < files[j].Name()
|
||||||
|
}
|
||||||
|
return infoI.ModTime().Before(infoJ.ModTime())
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 0; i < len(files)-keep; i++ {
|
||||||
|
path := filepath.Join(periodDir, files[i].Name())
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
return fmt.Errorf("remove old backup %s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createBackupArchive(destPath, dbPath, configPath string) error {
|
||||||
|
file, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
zipWriter := zip.NewWriter(file)
|
||||||
|
if err := addZipFile(zipWriter, dbPath); err != nil {
|
||||||
|
_ = zipWriter.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = addZipOptionalFile(zipWriter, dbPath+"-wal")
|
||||||
|
_ = addZipOptionalFile(zipWriter, dbPath+"-shm")
|
||||||
|
|
||||||
|
if strings.TrimSpace(configPath) != "" {
|
||||||
|
_ = addZipOptionalFile(zipWriter, configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := zipWriter.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return file.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
func addZipOptionalFile(writer *zip.Writer, path string) error {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return addZipFile(writer, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addZipFile(writer *zip.Writer, path string) error {
|
||||||
|
in, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
|
||||||
|
info, err := in.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
header, err := zip.FileInfoHeader(info)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
header.Name = filepath.Base(path)
|
||||||
|
header.Method = zip.Deflate
|
||||||
|
|
||||||
|
out, err := writer.CreateHeader(header)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(out, in)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Scheduler Hook (Main)
|
||||||
|
Add this to your `main.go` (or equivalent). This schedules daily backups and logs success.
|
||||||
|
|
||||||
|
```go
|
||||||
|
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
|
||||||
|
if cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hour, minute, err := parseBackupTime(cfg.Backup.Time)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err)
|
||||||
|
hour = 0
|
||||||
|
minute = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Startup check: if no backup exists for current periods, create now.
|
||||||
|
if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil {
|
||||||
|
slog.Error("local backup failed", "error", backupErr)
|
||||||
|
} else if len(created) > 0 {
|
||||||
|
for _, path := range created {
|
||||||
|
slog.Info("local backup completed", "archive", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
next := nextBackupTime(time.Now(), hour, minute)
|
||||||
|
timer := time.NewTimer(time.Until(next))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
start := time.Now()
|
||||||
|
created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath)
|
||||||
|
duration := time.Since(start)
|
||||||
|
if backupErr != nil {
|
||||||
|
slog.Error("local backup failed", "error", backupErr, "duration", duration)
|
||||||
|
} else {
|
||||||
|
for _, path := range created {
|
||||||
|
slog.Info("local backup completed", "archive", path, "duration", duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBackupTime(value string) (int, int, error) {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return 0, 0, fmt.Errorf("empty backup time")
|
||||||
|
}
|
||||||
|
parsed, err := time.Parse("15:04", value)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return parsed.Hour(), parsed.Minute(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextBackupTime(now time.Time, hour, minute int) time.Time {
|
||||||
|
location := now.Location()
|
||||||
|
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, location)
|
||||||
|
if !now.Before(target) {
|
||||||
|
target = target.Add(24 * time.Hour)
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Config Struct (Minimal)
|
||||||
|
Add to config:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type BackupConfig struct {
|
||||||
|
Time string `yaml:"time"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Default:
|
||||||
|
```go
|
||||||
|
if c.Backup.Time == "" {
|
||||||
|
c.Backup.Time = "00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Replication
|
||||||
|
- Keep `backup.time` in local time. Do **not** parse with timezone offsets unless required.
|
||||||
|
- The `.period.json` marker is what prevents duplicate backups within the same period.
|
||||||
|
- The archive file name only contains the date. Uniqueness is ensured by per-period directories and the period marker.
|
||||||
|
- If you change naming or retention, update both the file naming and prune logic together.
|
||||||
93
markdown
Normal file
93
markdown
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<p><strong>ТЕХНИЧЕСКОЕ ЗАДАНИЕ</strong></p>
|
||||||
|
<p>1. Требования к продукции</p>
|
||||||
|
<p>Поставляемое оборудование должно быть новым, оригинальным, не бывшим
|
||||||
|
в употреблении, не восстановленным. Гарантийный срок — не менее 12
|
||||||
|
месяцев с момента поставки. Все компоненты, включая процессоры, память,
|
||||||
|
накопители и контроллеры, должны быть совместимы и предварительно
|
||||||
|
протестированы на совместимость и производительность в рамках единой
|
||||||
|
системы.</p>
|
||||||
|
<p>2. Количественные и качественные характеристики</p>
|
||||||
|
<p>2.1. Базовые требования к серверной платформе:</p>
|
||||||
|
<p>Модель (артикул): Сервер в конфигурации с шасси, поддерживающим 12
|
||||||
|
отсека 2.5" для накопителей NVMe U.2/U.3.</p>
|
||||||
|
<p>Форм-фактор: 2U для установки в стойку.</p>
|
||||||
|
<p>Кол-во процессорных сокетов: 1.</p>
|
||||||
|
<p>2.2. Требования к процессорам (CPU):</p>
|
||||||
|
<p>Количество: 1 шт.</p>
|
||||||
|
<p>Модель/семейство: 256t x AMD EPYC 9755 2.7 GHz 128c-Core Processor
|
||||||
|
Объём кэша L3: 512MB Техпроцесс: 4 нм, архитектура процессора: Zen-5
|
||||||
|
(Turin).</p>
|
||||||
|
<p>Минимальная базовая тактовая частота: 2.7 ГГц.</p>
|
||||||
|
<p>Максимальная частота работы процессора (Turboboost): 4.1 GHz</p>
|
||||||
|
<p>Для обеспечения полной производительности всех 12 накопителей NVMe и
|
||||||
|
сетевых адаптеров, процессор и системная платформа в целом должны
|
||||||
|
обеспечивать достаточно линий PCIe 5.0.</p>
|
||||||
|
<p>2.3. Требования к оперативной памяти (RAM):</p>
|
||||||
|
<p>Тип памяти: DDR5 с коррекцией ошибок (ECC) RDIMM 6000Mhz.</p>
|
||||||
|
<p>Минимальный объем оперативной памяти: 2048 ГБ.</p>
|
||||||
|
<p>Конфигурация: Модули памяти должны быть установлены в оптимальной
|
||||||
|
конфигурации для обеспечения полной пропускной способности всех линий
|
||||||
|
PCIe 5.0 от NVMe-накопителей. Платформа должна поддерживать установку не
|
||||||
|
менее 16 модулей DDR5 ECC REG 6000Mhz для последующего
|
||||||
|
масштабирования.</p>
|
||||||
|
<p>Поддерживаемая частота: Не менее 6000 МТ/с.</p>
|
||||||
|
<p>Одобренные модули оперативной памяти - Samsung/Micron/Hynix, DDR5,
|
||||||
|
64GB, RDIMM, ECC</p>
|
||||||
|
<p>2.4 Требования к системе хранения данных:</p>
|
||||||
|
<p>Конфигурация шасси: Обязательна поставка в конфигурации с 12 отсеками
|
||||||
|
2.5" под горячую замену, поддерживающими интерфейс NVMe через PCIe 5.0
|
||||||
|
x4 и 2 отсеками 2.5"/М.2 под горячую замену, поддерживающими SATA.</p>
|
||||||
|
<p>Дополнительно (для ОС): Поддержка установки 2x M.2 NVMe накопителей в
|
||||||
|
dedicated-слотах на материнской плате под операционную систему отдельно
|
||||||
|
от основного хранилища данных.</p>
|
||||||
|
<p>2.5. Требования к сетевым интерфейсам (NIC):</p>
|
||||||
|
<p>Слоты расширения сети: Наличие не менее 1 слотов OCP 3.0 SFF для
|
||||||
|
установки специализированных сетевых адаптеров.</p>
|
||||||
|
<p>Дополнительные сетевые адаптеры (обязательная поставка): Один сетевой
|
||||||
|
адаптер OCP 3.0 с 2 портами 25 Гбит/с Ethernet Intel 810 или Mellanox
|
||||||
|
CX-6.</p>
|
||||||
|
<p>Встроенные порты управления: порт 1 Гбит/с Ethernet (RJ-45) для
|
||||||
|
модуля управления iBMC.</p>
|
||||||
|
<p>2.6. Требования к интерфейсам расширения (PCIe):</p>
|
||||||
|
<p>Количество слотов PCIe: Конфигурация с 12 дисками NVMe использует
|
||||||
|
большую часть линий PCIe. Тем не менее, не менее слотов PCIe 5.0 должны
|
||||||
|
оставаться свободными для будущего расширения (например, установки
|
||||||
|
дополнительных сетевых карт или FPGA/GPU для специфических задач).</p>
|
||||||
|
<p>Шинная архитектура: Поставщик должен предоставить схему распределения
|
||||||
|
линий PCIe 5.0 между процессором контроллером RAID и слотами расширения,
|
||||||
|
подтверждающую отсутствие узких мест (bottlenecks).</p>
|
||||||
|
<p>2.7. Требования к системе управления:</p>
|
||||||
|
<p>Внеполосный (out-of-band) модуль управления: Наличие выделенного чипа
|
||||||
|
iBMC.</p>
|
||||||
|
<p>Интеллектуальные функции: Критически важна поддержка детального
|
||||||
|
мониторинга состояния NVMe-накопителей (SMART, температура, износ,
|
||||||
|
прогнозирование сбоев) через интерфейс iBMC и поддержка технологии
|
||||||
|
горячей замены NVMe-накопителей.</p>
|
||||||
|
<p>Наличие безагентного КВМ (HTML5)</p>
|
||||||
|
<p>Желательна поддержка shared LAN (через NCSI OCPv3 разъем) с
|
||||||
|
тегированием VLAN, настройка по умолчанию DHCP IPv4</p>
|
||||||
|
<p>Управление параметрами работы сервера (режим работы вентиляторов,
|
||||||
|
потребление энергии, итд).</p>
|
||||||
|
<p>Наличие 2х видов логирования:</p>
|
||||||
|
<p>Все аппаратные события, включая ECC ошибки по памяти, ошибки PCIe,
|
||||||
|
SATA (IPMI/Hardware Event Log)</p>
|
||||||
|
<p>Все сессии аутентификации и изменения системных параметров
|
||||||
|
(Audit/Security Log)</p>
|
||||||
|
<p>Наличие функционала обновления прошивок сервера (BIOS, BMC, CPLD
|
||||||
|
(опционально)) с сохранением и без сохранения настроек.</p>
|
||||||
|
<p>2.8. Требования к системе питания и охлаждения:</p>
|
||||||
|
<p>Блоки питания (PSU):</p>
|
||||||
|
<p>Количество: 2 шт. (резервирование 1+1) с возможностью горячей
|
||||||
|
замены.</p>
|
||||||
|
<p>Номинальная мощность каждого: Минимум 1200 Вт с сертификацией 80 Plus
|
||||||
|
Platinum/Titanium. Мощность должна быть достаточной для одновременной
|
||||||
|
пиковой нагрузки от процессора, 12 NVMe-дисков и прочих компонентов.</p>
|
||||||
|
<p>Система охлаждения: не менее N+1 резервирование вентиляторов.</p>
|
||||||
|
<p>3. Упаковка и маркировка:</p>
|
||||||
|
<p>Оборудование должно быть упаковано так, чтобы предотвратить
|
||||||
|
повреждение при транспортировке.</p>
|
||||||
|
<p>4. Требования к комплектации:</p>
|
||||||
|
<p>Рельсовый комплект - без инструментов с горизонтальной загрузкой</p>
|
||||||
|
<p>Оборудование - C19-C20 или С13-С14 кабели питания 1-2 m в зависимости
|
||||||
|
от БП, 19 " комплект для монтажа в стойку, комплект винтов, все отсеки с
|
||||||
|
корзинами.</p>
|
||||||
41
memory.md
Normal file
41
memory.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Changes summary (2026-02-11)
|
||||||
|
|
||||||
|
Implemented strict `lot_category` flow using `pricelist_items.lot_category` only (no parsing from `lot_name`), plus local caching and backfill:
|
||||||
|
|
||||||
|
1. Local DB schema + migrations
|
||||||
|
- Added `lot_category` column to `local_pricelist_items` via `LocalPricelistItem` model.
|
||||||
|
- Added local migration `2026_02_11_local_pricelist_item_category` to add the column if missing and create indexes:
|
||||||
|
- `idx_local_pricelist_items_pricelist_lot (pricelist_id, lot_name)`
|
||||||
|
- `idx_local_pricelist_items_lot_category (lot_category)`
|
||||||
|
|
||||||
|
2. Server model/repository
|
||||||
|
- Added `LotCategory` field to `models.PricelistItem`.
|
||||||
|
- `PricelistRepository.GetItems` now sets `Category` from `LotCategory` (no parsing from `lot_name`).
|
||||||
|
|
||||||
|
3. Sync + local DB helpers
|
||||||
|
- `SyncPricelistItems` now saves `lot_category` into local cache via `PricelistItemToLocal`.
|
||||||
|
- Added `LocalDB.CountLocalPricelistItemsWithEmptyCategory` and `LocalDB.ReplaceLocalPricelistItems`.
|
||||||
|
- Added `LocalDB.GetLocalLotCategoriesByServerPricelistID` for strict category lookup.
|
||||||
|
- Added `SyncPricelists` backfill step: for used active pricelists with empty categories, force refresh items from server.
|
||||||
|
|
||||||
|
4. API handler
|
||||||
|
- `GET /api/pricelists/:id/items` returns `category` from `local_pricelist_items.lot_category` (no parsing from `lot_name`).
|
||||||
|
|
||||||
|
5. Article category foundation
|
||||||
|
- New package `internal/article`:
|
||||||
|
- `ResolveLotCategoriesStrict` pulls categories from local pricelist items and errors on missing category.
|
||||||
|
- `GroupForLotCategory` maps only allowed codes (CPU/MEM/GPU/M2/SSD/HDD/EDSFF/HHHL/NIC/HCA/DPU/PSU/PS) to article groups; excludes `SFP`.
|
||||||
|
- Error type `MissingCategoryForLotError` with base `ErrMissingCategoryForLot`.
|
||||||
|
|
||||||
|
6. Tests
|
||||||
|
- Added unit tests for converters and article category resolver.
|
||||||
|
- Added handler test to ensure `/api/pricelists/:id/items` returns `lot_category`.
|
||||||
|
- Added sync test for category backfill on used pricelist items.
|
||||||
|
- `go test ./...` passed.
|
||||||
|
|
||||||
|
Additional fixes (2026-02-11):
|
||||||
|
- Fixed article parsing bug: CPU/GPU parsers were swapped in `internal/article/generator.go`. CPU now uses last token from CPU lot; GPU uses model+memory from `GPU_vendor_model_mem_iface`.
|
||||||
|
- Adjusted configurator base tab layout to align labels on the same row (separate label row + input row grid).
|
||||||
|
|
||||||
|
UI rule (2026-02-19):
|
||||||
|
- In all breadcrumbs, truncate long specification/configuration names to 16 characters using ellipsis.
|
||||||
2
migrations/022_add_article_to_configurations.sql
Normal file
2
migrations/022_add_article_to_configurations.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE qt_configurations
|
||||||
|
ADD COLUMN IF NOT EXISTS article VARCHAR(80) NULL AFTER server_count;
|
||||||
2
migrations/023_add_server_model_to_configurations.sql
Normal file
2
migrations/023_add_server_model_to_configurations.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE qt_configurations
|
||||||
|
ADD COLUMN IF NOT EXISTS server_model VARCHAR(100) NULL AFTER server_count;
|
||||||
2
migrations/024_add_support_code_to_configurations.sql
Normal file
2
migrations/024_add_support_code_to_configurations.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE qt_configurations
|
||||||
|
ADD COLUMN IF NOT EXISTS support_code VARCHAR(20) NULL AFTER server_model;
|
||||||
38
migrations/025_add_project_code.sql
Normal file
38
migrations/025_add_project_code.sql
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
-- Add project code and enforce uniqueness
|
||||||
|
|
||||||
|
ALTER TABLE qt_projects
|
||||||
|
ADD COLUMN code VARCHAR(100) NULL AFTER owner_username;
|
||||||
|
|
||||||
|
-- Copy code from current project name (truncate to fit)
|
||||||
|
UPDATE qt_projects
|
||||||
|
SET code = LEFT(TRIM(COALESCE(name, '')), 100);
|
||||||
|
|
||||||
|
-- Fallback for any remaining blanks
|
||||||
|
UPDATE qt_projects
|
||||||
|
SET code = uuid
|
||||||
|
WHERE code IS NULL OR TRIM(code) = '';
|
||||||
|
|
||||||
|
-- Drop unique index if it already exists to allow de-duplication updates
|
||||||
|
DROP INDEX IF EXISTS idx_qt_projects_code ON qt_projects;
|
||||||
|
|
||||||
|
-- De-duplicate codes: OPS-1948-2, OPS-1948-3... (MariaDB without CTE)
|
||||||
|
UPDATE qt_projects p
|
||||||
|
JOIN (
|
||||||
|
SELECT p1.id,
|
||||||
|
p1.code AS base_code,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM qt_projects p2
|
||||||
|
WHERE p2.code = p1.code AND p2.id <= p1.id
|
||||||
|
) AS rn
|
||||||
|
FROM qt_projects p1
|
||||||
|
) r ON r.id = p.id
|
||||||
|
SET p.code = CASE
|
||||||
|
WHEN r.rn = 1 THEN r.base_code
|
||||||
|
ELSE CONCAT(LEFT(r.base_code, 90), '-', r.rn)
|
||||||
|
END;
|
||||||
|
|
||||||
|
ALTER TABLE qt_projects
|
||||||
|
MODIFY COLUMN code VARCHAR(100) NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_qt_projects_code ON qt_projects(code);
|
||||||
28
migrations/026_add_project_variant.sql
Normal file
28
migrations/026_add_project_variant.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- Add project variant and reset codes from project names
|
||||||
|
|
||||||
|
ALTER TABLE qt_projects
|
||||||
|
ADD COLUMN variant VARCHAR(100) NOT NULL DEFAULT '' AFTER code;
|
||||||
|
|
||||||
|
-- Drop legacy unique index on code to allow duplicate codes
|
||||||
|
DROP INDEX IF EXISTS idx_qt_projects_code ON qt_projects;
|
||||||
|
DROP INDEX IF EXISTS idx_qt_projects_code_variant ON qt_projects;
|
||||||
|
|
||||||
|
-- Reset code from name and clear variant
|
||||||
|
UPDATE qt_projects
|
||||||
|
SET code = LEFT(TRIM(COALESCE(name, '')), 100),
|
||||||
|
variant = '';
|
||||||
|
|
||||||
|
-- De-duplicate by assigning variant numbers: -2, -3...
|
||||||
|
UPDATE qt_projects p
|
||||||
|
JOIN (
|
||||||
|
SELECT p1.id,
|
||||||
|
p1.code,
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM qt_projects p2
|
||||||
|
WHERE p2.code = p1.code AND p2.id <= p1.id) AS rn
|
||||||
|
FROM qt_projects p1
|
||||||
|
) r ON r.id = p.id
|
||||||
|
SET p.code = r.code,
|
||||||
|
p.variant = CASE WHEN r.rn = 1 THEN '' ELSE CONCAT('-', r.rn) END;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_qt_projects_code_variant ON qt_projects(code, variant);
|
||||||
4
migrations/027_project_name_nullable.sql
Normal file
4
migrations/027_project_name_nullable.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- Allow NULL project names
|
||||||
|
|
||||||
|
ALTER TABLE qt_projects
|
||||||
|
MODIFY COLUMN name VARCHAR(200) NULL;
|
||||||
315
pricelists_window.md
Normal file
315
pricelists_window.md
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
# Промпт для ИИ: Перенос паттерна Прайслист
|
||||||
|
|
||||||
|
Используй этот документ как промпт для ИИ при переносе реализации прайслиста в другой проект.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
Я имею рабочую реализацию окна "Прайслист" в проекте QuoteForge. Нужно перенести эту реализацию в проект [ДОП_ПРОЕКТ_НАЗВАНИЕ], сохраняя структуру, логику и UI/UX.
|
||||||
|
|
||||||
|
## Что перенести
|
||||||
|
|
||||||
|
### Frontend - Лист прайслистов (`/pricelists`)
|
||||||
|
|
||||||
|
**Файл источник:** QuoteForge/web/templates/pricelists.html
|
||||||
|
|
||||||
|
**Компоненты:**
|
||||||
|
1. **Таблица** - список прайслистов с колонками:
|
||||||
|
- Версия (монофонт)
|
||||||
|
- Тип (estimate/warehouse/competitor)
|
||||||
|
- Дата создания
|
||||||
|
- Автор (обычно "sync")
|
||||||
|
- Позиций (количество товаров)
|
||||||
|
- Исп. (использований)
|
||||||
|
- Статус (зеленый "Активен" / серый "Неактивен")
|
||||||
|
- Действия (Просмотр, Удалить если не используется)
|
||||||
|
|
||||||
|
2. **Пагинация** - навигация по страницам с активной страницей выделена
|
||||||
|
|
||||||
|
3. **Модальное окно** - "Создать прайслист" (если есть прав на запись)
|
||||||
|
|
||||||
|
**Что копировать:**
|
||||||
|
- HTML структуру таблицы из lines 10-30
|
||||||
|
- JavaScript функции:
|
||||||
|
- `loadPricelists(page)` - загрузка списка
|
||||||
|
- `renderPricelists(items)` - рендер таблицы
|
||||||
|
- `renderPagination(total, page, perPage)` - пагинация
|
||||||
|
- `checkPricelistWritePermission()` - проверка прав
|
||||||
|
- Модальные функции: `openCreateModal()`, `closeCreateModal()`, `createPricelist()`
|
||||||
|
- CSS классы Tailwind (скопируются как есть)
|
||||||
|
|
||||||
|
**Где использовать в дочернем проекте:**
|
||||||
|
- URL: `/pricelists` (или адаптировать под ваши маршруты)
|
||||||
|
- API: `GET /api/pricelists?page=1&per_page=20`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend - Детали прайслиста (`/pricelists/:id`)
|
||||||
|
|
||||||
|
**Файл источник:** QuoteForge/web/templates/pricelist_detail.html
|
||||||
|
|
||||||
|
**Компоненты:**
|
||||||
|
1. **Хлебная крошка** - кнопка назад на список
|
||||||
|
|
||||||
|
2. **Инфо-панель** - сводка по прайслисту:
|
||||||
|
- Версия (монофонт)
|
||||||
|
- Дата создания
|
||||||
|
- Автор
|
||||||
|
- Позиций (количество)
|
||||||
|
- Использований (в скольких конфигах)
|
||||||
|
- Статус (зеленый/серый)
|
||||||
|
- Истекает (дата или "-")
|
||||||
|
|
||||||
|
3. **Таблица товаров** - с поиском и пагинацией:
|
||||||
|
- Артикул (монофонт, lot_name)
|
||||||
|
- Категория (извлекается первая часть до "_")
|
||||||
|
- Описание (обрезается до 60 символов с "...")
|
||||||
|
- [УСЛОВНО] Доступно (qty) - только для warehouse источника
|
||||||
|
- [УСЛОВНО] Partnumbers - только для warehouse источника
|
||||||
|
- Цена, $ (с 2 знаками после запятой)
|
||||||
|
- Настройки (аббревиатуры: РУЧН, Сред, Взвеш.мед, периоды (1н, 1м, 3м, 1г), коэффициент, МЕТА)
|
||||||
|
|
||||||
|
4. **Поиск** - дебаунс 300мс, поиск по lot_name
|
||||||
|
|
||||||
|
5. **Динамические колонки** - qty и partnumbers скрываются/показываются в зависимости от source (warehouse или нет)
|
||||||
|
|
||||||
|
**Что копировать:**
|
||||||
|
- HTML структуру из lines 4-78
|
||||||
|
- JavaScript функции:
|
||||||
|
- `loadPricelistInfo()` - загрузка деталей прайслиста
|
||||||
|
- `loadItems(page)` - загрузка товаров
|
||||||
|
- `renderItems(items)` - рендер таблицы товаров
|
||||||
|
- `renderItemsPagination(total, page, perPage)` - пагинация товаров
|
||||||
|
- `isWarehouseSource()` - проверка источника
|
||||||
|
- `toggleWarehouseColumns()` - показать/скрыть conditional колонки
|
||||||
|
- `formatQty(qty)` - форматирование количества
|
||||||
|
- `formatPriceSettings(item)` - форматирование строки настроек
|
||||||
|
- `escapeHtml(text)` - экранирование HTML
|
||||||
|
- Debounce для поиска (lines 300-306)
|
||||||
|
- CSS классы Tailwind
|
||||||
|
- Логику conditional колонок (lines 152-164)
|
||||||
|
|
||||||
|
**Где использовать в дочернем проекте:**
|
||||||
|
- URL: `/pricelists/:id`
|
||||||
|
- API:
|
||||||
|
- `GET /api/pricelists/:id`
|
||||||
|
- `GET /api/pricelists/:id/items?page=1&per_page=50&search=...`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Backend - Handler
|
||||||
|
|
||||||
|
**Файл источник:** QuoteForge/internal/handlers/pricelist.go
|
||||||
|
|
||||||
|
**Методы для реализации:**
|
||||||
|
|
||||||
|
1. **List** (lines 23-89)
|
||||||
|
- Параметры: `page`, `per_page`, `source` (фильтр), `active_only`
|
||||||
|
- Логика:
|
||||||
|
- Получить все прайслисты
|
||||||
|
- Отфильтровать по source (case-insensitive)
|
||||||
|
- Отсортировать по CreatedAt DESC (свежее сверху)
|
||||||
|
- Пагинировать
|
||||||
|
- Для каждого: посчитать товары (CountLocalPricelistItems), использования (IsUsed)
|
||||||
|
- Вернуть JSON с полями: id, source, version, created_by, item_count, usage_count, is_active, created_at, synced_from
|
||||||
|
|
||||||
|
2. **Get** (lines 92-116)
|
||||||
|
- Параметр: `id` (uint из URL)
|
||||||
|
- Логика:
|
||||||
|
- Получить прайслист по ID
|
||||||
|
- Вернуть его детали (id, source, version, item_count, is_active, created_at)
|
||||||
|
- 404 если не найден
|
||||||
|
|
||||||
|
3. **GetItems** (lines 119-181)
|
||||||
|
- Параметры: `id` (URL), `page`, `per_page`, `search` (query)
|
||||||
|
- Логика:
|
||||||
|
- Получить прайслист по ID
|
||||||
|
- Получить товары этого прайслиста
|
||||||
|
- Фильтровать по lot_name LIKE search (если передан)
|
||||||
|
- Посчитать total
|
||||||
|
- Пагинировать
|
||||||
|
- Для каждого товара: извлечь категорию из lot_name (первая часть до "_")
|
||||||
|
- Вернуть JSON: source, items (id, lot_name, price, category, available_qty, partnumbers), total, page, per_page
|
||||||
|
|
||||||
|
4. **GetLotNames** (lines 183-211)
|
||||||
|
- Параметр: `id` (URL)
|
||||||
|
- Логика:
|
||||||
|
- Получить все lot_names из этого прайслиста
|
||||||
|
- Отсортировать alphabetically
|
||||||
|
- Вернуть JSON: lot_names (array of strings), total
|
||||||
|
|
||||||
|
5. **GetLatest** (lines 214-233)
|
||||||
|
- Параметр: `source` (query, default "estimate")
|
||||||
|
- Логика:
|
||||||
|
- Нормализовать source (case-insensitive)
|
||||||
|
- Получить самый свежий прайслист по этому source
|
||||||
|
- Вернуть его детали
|
||||||
|
- 404 если не найден
|
||||||
|
|
||||||
|
**Регистрация маршрутов:**
|
||||||
|
```go
|
||||||
|
pricelists := api.Group("/pricelists")
|
||||||
|
{
|
||||||
|
pricelists.GET("", handler.List)
|
||||||
|
pricelists.GET("/latest", handler.GetLatest)
|
||||||
|
pricelists.GET("/:id", handler.Get)
|
||||||
|
pricelists.GET("/:id/items", handler.GetItems)
|
||||||
|
pricelists.GET("/:id/lots", handler.GetLotNames)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Адаптация для другого проекта
|
||||||
|
|
||||||
|
### Что нужно изменить
|
||||||
|
|
||||||
|
1. **Источник данных**
|
||||||
|
- QuoteForge использует local DB (LocalPricelist, LocalPricelistItem)
|
||||||
|
- В вашем проекте: замените на ваши структуры/таблицы
|
||||||
|
- Сущность "прайслист" может называться по-другому
|
||||||
|
|
||||||
|
2. **API маршруты**
|
||||||
|
- `/api/pricelists` → ваш путь
|
||||||
|
- `:id` - может быть UUID вместо int, адаптировать parsing
|
||||||
|
|
||||||
|
3. **Имена полей**
|
||||||
|
- Если у вас нет поля `version` - используйте ID или дату
|
||||||
|
- Если нет `source` - опустить фильтр
|
||||||
|
- Если нет `IsUsed` - считать как всегда 0
|
||||||
|
|
||||||
|
4. **Структуры данных**
|
||||||
|
- Pricelist должна иметь: id, name/version, created_at, source, item_count
|
||||||
|
- PricelistItem должна иметь: id, lot_name, price, available_qty, partnumbers
|
||||||
|
|
||||||
|
5. **Условные колонки**
|
||||||
|
- Логика: если source == "warehouse", показать qty и partnumbers
|
||||||
|
- Адаптировать под ваши источники/типы
|
||||||
|
|
||||||
|
### Что копировать как есть
|
||||||
|
|
||||||
|
- **HTML структура** - таблицы, модали, классы Tailwind
|
||||||
|
- **JavaScript логика** - все функции загрузки, рендера, пагинации
|
||||||
|
- **CSS классы** - Tailwind работает везде одинаково
|
||||||
|
- **Форматирование функций** - formatPrice, formatQty, formatDate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Пошаговая инструкция для ИИ
|
||||||
|
|
||||||
|
1. **Прочитай оба файла:**
|
||||||
|
- QuoteForge/web/templates/pricelists.html (список)
|
||||||
|
- QuoteForge/web/templates/pricelist_detail.html (детали)
|
||||||
|
- QuoteForge/internal/handlers/pricelist.go (backend)
|
||||||
|
|
||||||
|
2. **Определи структуры данных в дочернем проекте:**
|
||||||
|
- Какая таблица хранит "прайслисты"?
|
||||||
|
- Какие у неё поля?
|
||||||
|
- Как связаны товары?
|
||||||
|
|
||||||
|
3. **Адаптируй Backend:**
|
||||||
|
- Скопируй методы Handler
|
||||||
|
- Замени DB вызовы на вызовы вашего хранилища
|
||||||
|
- Замени имена полей в JSON ответах если нужно
|
||||||
|
- Убедись, что API возвращает нужный формат
|
||||||
|
|
||||||
|
4. **Адаптируй Frontend - Список:**
|
||||||
|
- Скопируй HTML таблицу
|
||||||
|
- Скопируй функции load/render/pagination
|
||||||
|
- Замени маршруты `/pricelists` → ваши
|
||||||
|
- Замени API endpoint → ваш
|
||||||
|
- Протестируй список загружается
|
||||||
|
|
||||||
|
5. **Адаптируй Frontend - Детали:**
|
||||||
|
- Скопируй HTML для деталей
|
||||||
|
- Скопируй функции loadInfo/loadItems/render
|
||||||
|
- Замени маршруты и endpoints
|
||||||
|
- Особое внимание на conditional колонки (toggleWarehouseColumns)
|
||||||
|
- Протестируй поиск работает
|
||||||
|
|
||||||
|
6. **Протестируй:**
|
||||||
|
- Список загружается
|
||||||
|
- Пагинация работает
|
||||||
|
- Детали открываются
|
||||||
|
- Поиск работает
|
||||||
|
- Conditional колонки показываются/скрываются правильно
|
||||||
|
- Форматирование цен и дат работает
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Пример адаптации
|
||||||
|
|
||||||
|
### Backend (было):
|
||||||
|
```go
|
||||||
|
func (h *PricelistHandler) List(c *gin.Context) {
|
||||||
|
localPLs, err := h.localDB.GetLocalPricelists()
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend (стало):
|
||||||
|
```go
|
||||||
|
func (h *CatalogHandler) List(c *gin.Context) {
|
||||||
|
catalogs, err := h.service.GetAllCatalogs(page, perPage)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (было):
|
||||||
|
```javascript
|
||||||
|
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (стало):
|
||||||
|
```javascript
|
||||||
|
const resp = await fetch(`/api/catalogs?page=${page}&per_page=20`);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Качество результата
|
||||||
|
|
||||||
|
Когда закончишь:
|
||||||
|
- ✅ Список и детали выглядят идентично QuoteForge
|
||||||
|
- ✅ Все функции работают (load, render, pagination, search, conditional columns)
|
||||||
|
- ✅ Обработка ошибок (404, empty list, network errors)
|
||||||
|
- ✅ Таблицы с Tailwind классами оформлены одинаково
|
||||||
|
- ✅ Форматирование чисел/дат совпадает
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Вопросы для ИИ
|
||||||
|
|
||||||
|
Перед тем как давать этот промпт, ответь на эти вопросы:
|
||||||
|
|
||||||
|
1. **Какие у тебя структуры данных для "прайслиста"?**
|
||||||
|
- Пример: какие поля, как называется таблица
|
||||||
|
|
||||||
|
2. **Какие API endpoints уже есть?**
|
||||||
|
- Или нужно создать с нуля?
|
||||||
|
|
||||||
|
3. **Есть ли уже разница в источниках (estimate/warehouse)?**
|
||||||
|
- Или все одного типа?
|
||||||
|
|
||||||
|
4. **Нужна ли возможность создавать прайслисты?**
|
||||||
|
- Или только просмотр?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Чеклист для проверки
|
||||||
|
|
||||||
|
После переноса проверь:
|
||||||
|
|
||||||
|
- [ ] Backend: List возвращает правильный JSON
|
||||||
|
- [ ] Backend: Get возвращает детали
|
||||||
|
- [ ] Backend: GetItems возвращает товары с поиском
|
||||||
|
- [ ] Frontend: Список загружается на `/pricelists`
|
||||||
|
- [ ] Frontend: Клик на прайслист открывает `/pricelists/:id`
|
||||||
|
- [ ] Frontend: Таблица на детальной странице рендеритсяся
|
||||||
|
- [ ] Frontend: Поиск работает с дебаунсом
|
||||||
|
- [ ] Frontend: Пагинация работает
|
||||||
|
- [ ] Frontend: Conditional колонки показываются/скрываются
|
||||||
|
- [ ] Frontend: Форматирование цен работает (2 знака)
|
||||||
|
- [ ] Frontend: Форматирование дат работает (ru-RU)
|
||||||
|
- [ ] UI: Выглядит идентично QuoteForge
|
||||||
59
releases/memory/v1.2.2.md
Normal file
59
releases/memory/v1.2.2.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Release v1.2.2 (2026-02-09)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Fixed CSV export filename inconsistency where project names weren't being resolved correctly. Standardized export format across both manual exports and project configuration exports to use `YYYY-MM-DD (project_name) config_name BOM.csv`.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
- `8f596ce` fix: standardize CSV export filename format to use project name
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### CSV Export Filename Standardization
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
- ExportCSV and ExportConfigCSV had inconsistent filename formats
|
||||||
|
- Project names sometimes fell back to config names when not explicitly provided
|
||||||
|
- Export timestamps didn't reflect actual price update time
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Unified format: `YYYY-MM-DD (project_name) config_name BOM.csv`
|
||||||
|
- Both export paths now use PriceUpdatedAt if available, otherwise CreatedAt
|
||||||
|
- Project name resolved from ProjectUUID via ProjectService for both paths
|
||||||
|
- Frontend passes project_uuid context when exporting
|
||||||
|
|
||||||
|
**Technical Details:**
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
- Added `ProjectUUID` field to `ExportRequest` struct in handlers/export.go
|
||||||
|
- Updated ExportCSV to look up project name from ProjectUUID using ProjectService
|
||||||
|
- Ensured ExportConfigCSV gets project name from config's ProjectUUID
|
||||||
|
- Both use CreatedAt (for ExportCSV) or PriceUpdatedAt/CreatedAt (for ExportConfigCSV)
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
- Added `projectUUID` and `projectName` state variables in index.html
|
||||||
|
- Load and store projectUUID when configuration is loaded
|
||||||
|
- Pass `project_uuid` in JSON body for both export requests
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
- `internal/handlers/export.go` - Project name resolution and ExportRequest update
|
||||||
|
- `internal/handlers/export_test.go` - Updated mock initialization with projectService param
|
||||||
|
- `cmd/qfs/main.go` - Pass projectService to ExportHandler constructor
|
||||||
|
- `web/templates/index.html` - Add projectUUID tracking and export payload updates
|
||||||
|
|
||||||
|
## Testing Notes
|
||||||
|
|
||||||
|
✅ All existing tests updated and passing
|
||||||
|
✅ Code builds without errors
|
||||||
|
✅ Export filename now includes correct project name
|
||||||
|
✅ Works for both form-based and project-based exports
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
None - API response format unchanged, only filename generation updated.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
None identified.
|
||||||
95
releases/memory/v1.2.3.md
Normal file
95
releases/memory/v1.2.3.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Release v1.2.3 (2026-02-10)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Unified synchronization functionality with event-driven UI updates. Resolved user confusion about duplicate sync buttons by implementing a single sync source with automatic page refreshes.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### Main Feature: Sync Event System
|
||||||
|
|
||||||
|
- **Added `sync-completed` event** in base.html's `syncAction()` function
|
||||||
|
- Dispatched after successful `/api/sync/all` or `/api/sync/push`
|
||||||
|
- Includes endpoint and response data in event detail
|
||||||
|
- Enables pages to react automatically to sync completion
|
||||||
|
|
||||||
|
### Configs Page (`configs.html`)
|
||||||
|
|
||||||
|
- **Removed "Импорт с сервера" button** - duplicate functionality no longer needed
|
||||||
|
- **Updated layout** - changed from 2-column grid to single button layout
|
||||||
|
- **Removed `importConfigsFromServer()` function** - functionality now handled by navbar sync
|
||||||
|
- **Added sync-completed event listener**:
|
||||||
|
- Automatically reloads configurations list after sync
|
||||||
|
- Resets pagination to first page
|
||||||
|
- New configurations appear immediately without manual refresh
|
||||||
|
|
||||||
|
### Projects Page (`projects.html`)
|
||||||
|
|
||||||
|
- **Wrapped initialization in DOMContentLoaded**:
|
||||||
|
- Moved `loadProjects()` and all event listeners inside handler
|
||||||
|
- Ensures DOM is fully loaded before accessing elements
|
||||||
|
- **Added sync-completed event listener**:
|
||||||
|
- Automatically reloads projects list after sync
|
||||||
|
- New projects appear immediately without manual refresh
|
||||||
|
|
||||||
|
### Pricelists Page (`pricelists.html`)
|
||||||
|
|
||||||
|
- **Added sync-completed event listener** to existing DOMContentLoaded:
|
||||||
|
- Automatically reloads pricelists when sync completes
|
||||||
|
- Maintains existing permissions and modal functionality
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- ✅ Single "Синхронизация" button in navbar - no confusion about sync sources
|
||||||
|
- ✅ Automatic list updates after sync - no need for manual F5 refresh
|
||||||
|
- ✅ Consistent behavior across all pages (configs, projects, pricelists)
|
||||||
|
- ✅ Better feedback: toast notification + automatic UI refresh
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- ✅ Event-driven loose coupling between navbar and pages
|
||||||
|
- ✅ Easy to extend to other pages (just add event listener)
|
||||||
|
- ✅ No backend changes needed
|
||||||
|
- ✅ Production-ready
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
- **`/api/configs/import` endpoint** still works but UI button removed
|
||||||
|
- Users should use navbar "Синхронизация" button instead
|
||||||
|
- Backend API remains unchanged for backward compatibility
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
1. `web/templates/base.html` - Added sync-completed event dispatch
|
||||||
|
2. `web/templates/configs.html` - Event listener + removed duplicate UI
|
||||||
|
3. `web/templates/projects.html` - DOMContentLoaded wrapper + event listener
|
||||||
|
4. `web/templates/pricelists.html` - Event listener for auto-refresh
|
||||||
|
|
||||||
|
**Stats:** 4 files changed, 59 insertions(+), 65 deletions(-)
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
- `99fd80b` - feat: unify sync functionality with event-driven UI updates
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [x] Configs page: New configurations appear after navbar sync
|
||||||
|
- [x] Projects page: New projects appear after navbar sync
|
||||||
|
- [x] Pricelists page: Pricelists refresh after navbar sync
|
||||||
|
- [x] Both `/api/sync/all` and `/api/sync/push` trigger updates
|
||||||
|
- [x] Toast notifications still show correctly
|
||||||
|
- [x] Sync status indicator updates
|
||||||
|
- [x] Error handling (423, network errors) still works
|
||||||
|
- [x] Mode switching (Active/Archive) works correctly
|
||||||
|
- [x] Backward compatibility maintained
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
None - implementation is production-ready
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
No migration needed. Changes are frontend-only and backward compatible:
|
||||||
|
- Old `/api/configs/import` endpoint still functional
|
||||||
|
- No database schema changes
|
||||||
|
- No configuration changes needed
|
||||||
68
releases/memory/v1.3.0.md
Normal file
68
releases/memory/v1.3.0.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Release v1.3.0 (2026-02-11)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Introduced article generation with pricelist categories, added local configuration storage, and expanded sync/export capabilities. Simplified article generator compression and loosened project update constraints.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### Main Features: Articles + Pricelist Categories
|
||||||
|
|
||||||
|
- **Article generation pipeline**
|
||||||
|
- New generator and tests under `internal/article/`
|
||||||
|
- Category support with test coverage
|
||||||
|
- **Pricelist category integration**
|
||||||
|
- Handler and repository updates
|
||||||
|
- Sync backfill test for category propagation
|
||||||
|
|
||||||
|
### Local Configuration Storage
|
||||||
|
|
||||||
|
- **Local DB support**
|
||||||
|
- New localdb models, converters, snapshots, and migrations
|
||||||
|
- Local configuration service for cached configurations
|
||||||
|
|
||||||
|
### Export & UI
|
||||||
|
|
||||||
|
- **Export handler updates** for article data output
|
||||||
|
- **Configs and index templates** adjusted for new article-related fields
|
||||||
|
|
||||||
|
### Behavior Changes
|
||||||
|
|
||||||
|
- **Cross-user project updates allowed**
|
||||||
|
- Removed restriction in project service
|
||||||
|
- **Article compression refinement**
|
||||||
|
- Generator logic simplified to reduce complexity
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
None identified. Existing APIs remain intact.
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
1. `internal/article/*` - Article generator + categories + tests
|
||||||
|
2. `internal/localdb/*` - Local DB models, migrations, snapshots
|
||||||
|
3. `internal/handlers/export.go` - Export updates
|
||||||
|
4. `internal/handlers/pricelist.go` - Category handling
|
||||||
|
5. `internal/services/sync/service.go` - Category backfill logic
|
||||||
|
6. `web/templates/configs.html` - Article field updates
|
||||||
|
7. `web/templates/index.html` - Article field updates
|
||||||
|
|
||||||
|
**Stats:** 33 files changed, 2059 insertions(+), 329 deletions(-)
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
- `5edffe8` - Add article generation and pricelist categories
|
||||||
|
- `e355903` - Allow cross-user project updates
|
||||||
|
- `e58fd35` - Refine article compression and simplify generator
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Tests not run (not requested)
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
- New migrations:
|
||||||
|
- `022_add_article_to_configurations.sql`
|
||||||
|
- `023_add_server_model_to_configurations.sql`
|
||||||
|
- `024_add_support_code_to_configurations.sql`
|
||||||
|
- Ensure migrations are applied before running v1.3.0
|
||||||
66
releases/memory/v1.3.2.md
Normal file
66
releases/memory/v1.3.2.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Release v1.3.2 (2026-02-19)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Release focuses on stability and data integrity for local configurations. Added configuration revision history, stronger recovery for broken local sync/version states, improved sync self-healing, and clearer API error logging.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### Configuration Revisions
|
||||||
|
|
||||||
|
- Added full local configuration revision flow with storage and UI support.
|
||||||
|
- Introduced revisions page/template and backend plumbing for browsing revisions.
|
||||||
|
- Prevented duplicate revisions when content did not actually change.
|
||||||
|
|
||||||
|
### Local Data Integrity and Recovery
|
||||||
|
|
||||||
|
- Added migration and snapshot support for local configuration version data.
|
||||||
|
- Hardened updates for legacy/orphaned configuration rows:
|
||||||
|
- allow update when project UUID is unchanged even if referenced project is missing locally;
|
||||||
|
- recover gracefully when `current_version_id` is stale or version rows are missing.
|
||||||
|
- Added regression tests for orphan-project and missing-current-version scenarios.
|
||||||
|
|
||||||
|
### Sync Reliability
|
||||||
|
|
||||||
|
- Added smart self-healing path for sync errors.
|
||||||
|
- Fixed duplicate-project sync edge cases.
|
||||||
|
|
||||||
|
### API and Logging
|
||||||
|
|
||||||
|
- Improved HTTP error mapping for configuration updates (`404/403` instead of generic `500` in known cases).
|
||||||
|
- Enhanced request logger to capture error responses (status, response body snippet, gin errors) for failed requests.
|
||||||
|
|
||||||
|
### UI and Export
|
||||||
|
|
||||||
|
- Updated project detail and index templates for revisions and related UX improvements.
|
||||||
|
- Updated export pipeline and tests to align with revisions/project behavior changes.
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
None identified.
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
- 24 files changed, 2394 insertions(+), 482 deletions(-)
|
||||||
|
- Main touched areas:
|
||||||
|
- `/Users/mchusavitin/Documents/git/QuoteForge/internal/services/local_configuration.go`
|
||||||
|
- `/Users/mchusavitin/Documents/git/QuoteForge/internal/services/local_configuration_versioning_test.go`
|
||||||
|
- `/Users/mchusavitin/Documents/git/QuoteForge/internal/localdb/{localdb.go,migrations.go,snapshots.go,local_migrations_test.go}`
|
||||||
|
- `/Users/mchusavitin/Documents/git/QuoteForge/internal/services/export.go`
|
||||||
|
- `/Users/mchusavitin/Documents/git/QuoteForge/cmd/qfs/main.go`
|
||||||
|
- `/Users/mchusavitin/Documents/git/QuoteForge/web/templates/{config_revisions.html,project_detail.html,index.html,base.html}`
|
||||||
|
|
||||||
|
## Commits Included (`v1.3.1..v1.3.2`)
|
||||||
|
|
||||||
|
- `b153afb` - Add smart self-healing for sync errors
|
||||||
|
- `8508ee2` - Fix sync errors for duplicate projects and add modal scrolling
|
||||||
|
- `2e973b6` - Add configuration revisions system and project variant deletion
|
||||||
|
- `71f73e2` - chore: save current changes
|
||||||
|
- `cbaeafa` - Deduplicate configuration revisions and update revisions UI
|
||||||
|
- `075fc70` - Harden local config updates and error logging
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- [x] Targeted tests for local configuration update/version recovery:
|
||||||
|
- `go test ./internal/services -run 'TestUpdateNoAuth(AllowsOrphanProjectWhenUUIDUnchanged|RecoversWhenCurrentVersionMissing|KeepsProjectWhenProjectUUIDOmitted)$'`
|
||||||
|
- [ ] Full regression suite not run in this release step.
|
||||||
@@ -45,10 +45,10 @@
|
|||||||
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
|
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
|
||||||
|
|
||||||
<!-- Sync Info Modal -->
|
<!-- Sync Info Modal -->
|
||||||
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden p-4">
|
||||||
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4">
|
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full max-h-[90vh] flex flex-col">
|
||||||
<div class="p-6">
|
<div class="p-6 border-b border-gray-200 flex-shrink-0">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center">
|
||||||
<h3 class="text-lg font-medium text-gray-900">Информация о синхронизации</h3>
|
<h3 class="text-lg font-medium text-gray-900">Информация о синхронизации</h3>
|
||||||
<button onclick="closeSyncModal()" class="text-gray-400 hover:text-gray-500">
|
<button onclick="closeSyncModal()" class="text-gray-400 hover:text-gray-500">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -56,8 +56,10 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-5">
|
<div class="overflow-y-auto flex-1">
|
||||||
|
<div class="p-6 space-y-5">
|
||||||
<!-- Section 1: DB Connection -->
|
<!-- Section 1: DB Connection -->
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-medium text-gray-900 mb-2">Подключение к БД</h4>
|
<h4 class="font-medium text-gray-900 mb-2">Подключение к БД</h4>
|
||||||
@@ -123,9 +125,31 @@
|
|||||||
<h4 class="font-medium text-gray-900 mb-2">Ошибки синхронизации</h4>
|
<h4 class="font-medium text-gray-900 mb-2">Ошибки синхронизации</h4>
|
||||||
<div id="modal-errors-list" class="text-sm max-h-40 overflow-y-auto space-y-1"></div>
|
<div id="modal-errors-list" class="text-sm max-h-40 overflow-y-auto space-y-1"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<!-- Section 5: Self-Healing (shown only if errors exist) -->
|
||||||
|
<div id="modal-repair-section" class="hidden">
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<h4 class="font-medium text-blue-900 mb-2">Автоматическое исправление</h4>
|
||||||
|
<p class="text-sm text-blue-700 mb-3">
|
||||||
|
Система может исправить данные и очистить ошибки синхронизации:
|
||||||
|
</p>
|
||||||
|
<ul class="text-sm text-blue-700 mb-3 ml-4 list-disc space-y-1">
|
||||||
|
<li>Проверит и исправит названия проектов</li>
|
||||||
|
<li>Восстановит битые ссылки на проекты</li>
|
||||||
|
<li>Очистит ошибки и даст pending changes еще шанс</li>
|
||||||
|
</ul>
|
||||||
|
<button id="repair-button" onclick="repairPendingChanges()"
|
||||||
|
class="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
ИСПРАВИТЬ
|
||||||
|
</button>
|
||||||
|
<div id="repair-result" class="mt-2 text-sm hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 border-t border-gray-200 flex-shrink-0">
|
||||||
|
<div class="flex justify-end">
|
||||||
<button onclick="closeSyncModal()" class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700">
|
<button onclick="closeSyncModal()" class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700">
|
||||||
Закрыть
|
Закрыть
|
||||||
</button>
|
</button>
|
||||||
@@ -235,7 +259,8 @@
|
|||||||
// Section 4: Errors
|
// Section 4: Errors
|
||||||
const errorsSection = document.getElementById('modal-errors-section');
|
const errorsSection = document.getElementById('modal-errors-section');
|
||||||
const errorsList = document.getElementById('modal-errors-list');
|
const errorsList = document.getElementById('modal-errors-list');
|
||||||
if (data.errors && data.errors.length > 0) {
|
const hasErrors = data.errors && data.errors.length > 0;
|
||||||
|
if (hasErrors) {
|
||||||
errorsSection.classList.remove('hidden');
|
errorsSection.classList.remove('hidden');
|
||||||
errorsList.innerHTML = data.errors.map(error => {
|
errorsList.innerHTML = data.errors.map(error => {
|
||||||
const time = new Date(error.timestamp).toLocaleString('ru-RU');
|
const time = new Date(error.timestamp).toLocaleString('ru-RU');
|
||||||
@@ -246,12 +271,65 @@
|
|||||||
} else {
|
} else {
|
||||||
errorsSection.classList.add('hidden');
|
errorsSection.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Section 5: Repair (show only if errors exist)
|
||||||
|
const repairSection = document.getElementById('modal-repair-section');
|
||||||
|
const repairResult = document.getElementById('repair-result');
|
||||||
|
if (hasErrors) {
|
||||||
|
repairSection.classList.remove('hidden');
|
||||||
|
repairResult.classList.add('hidden');
|
||||||
|
repairResult.innerHTML = '';
|
||||||
|
} else {
|
||||||
|
repairSection.classList.add('hidden');
|
||||||
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error('Failed to load sync info:', e);
|
console.error('Failed to load sync info:', e);
|
||||||
document.getElementById('modal-db-status').textContent = 'Ошибка загрузки';
|
document.getElementById('modal-db-status').textContent = 'Ошибка загрузки';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Repair pending changes
|
||||||
|
async function repairPendingChanges() {
|
||||||
|
const button = document.getElementById('repair-button');
|
||||||
|
const resultDiv = document.getElementById('repair-result');
|
||||||
|
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = 'Исправление...';
|
||||||
|
resultDiv.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/sync/repair', { method: 'POST' });
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
if (data.repaired > 0) {
|
||||||
|
resultDiv.className = 'mt-2 text-sm text-green-700 bg-green-50 rounded px-3 py-2';
|
||||||
|
resultDiv.textContent = `✓ Исправлено: ${data.repaired}`;
|
||||||
|
// Reload sync info after repair
|
||||||
|
setTimeout(() => loadSyncInfo(), 1000);
|
||||||
|
} else {
|
||||||
|
resultDiv.className = 'mt-2 text-sm text-yellow-700 bg-yellow-50 rounded px-3 py-2';
|
||||||
|
resultDiv.textContent = 'Нечего исправлять или проблемы остались';
|
||||||
|
if (data.remaining_errors && data.remaining_errors.length > 0) {
|
||||||
|
resultDiv.innerHTML += '<div class="mt-1 text-xs">' + data.remaining_errors.join('<br>') + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
resultDiv.className = 'mt-2 text-sm text-red-700 bg-red-50 rounded px-3 py-2';
|
||||||
|
resultDiv.textContent = 'Ошибка: ' + (data.error || 'неизвестная ошибка');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
resultDiv.className = 'mt-2 text-sm text-red-700 bg-red-50 rounded px-3 py-2';
|
||||||
|
resultDiv.textContent = 'Ошибка запроса: ' + e.message;
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = 'ИСПРАВИТЬ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Event delegation for sync dropdown and actions
|
// Event delegation for sync dropdown and actions
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadDBUser();
|
loadDBUser();
|
||||||
@@ -285,6 +363,14 @@
|
|||||||
showToast(successMessage, 'success');
|
showToast(successMessage, 'success');
|
||||||
// Update last sync time - removed since dropdown is gone
|
// Update last sync time - removed since dropdown is gone
|
||||||
// loadLastSyncTime();
|
// loadLastSyncTime();
|
||||||
|
|
||||||
|
// Dispatch custom event for pages to react to sync completion
|
||||||
|
window.dispatchEvent(new CustomEvent('sync-completed', {
|
||||||
|
detail: {
|
||||||
|
endpoint: endpoint,
|
||||||
|
data: data
|
||||||
|
}
|
||||||
|
}));
|
||||||
} else if (resp.status === 423) {
|
} else if (resp.status === 423) {
|
||||||
const reason = data.reason_text || data.error || 'Синхронизация заблокирована.';
|
const reason = data.reason_text || data.error || 'Синхронизация заблокирована.';
|
||||||
showToast(reason, 'error');
|
showToast(reason, 'error');
|
||||||
|
|||||||
238
web/templates/config_revisions.html
Normal file
238
web/templates/config_revisions.html
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
{{define "title"}}Ревизии - QuoteForge{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
|
||||||
|
<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 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<div class="text-2xl font-bold flex items-center gap-2" id="revisions-breadcrumbs">
|
||||||
|
<a id="breadcrumb-code-link" href="/projects" class="text-blue-700 hover:underline">
|
||||||
|
<span id="breadcrumb-code">—</span>
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<a id="breadcrumb-variant-link" href="/projects" class="text-blue-700 hover:underline">
|
||||||
|
<span id="breadcrumb-variant">main</span>
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<a id="breadcrumb-config-link" href="/configurator" class="text-blue-700 hover:underline">
|
||||||
|
<span id="breadcrumb-config">—</span>
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<span class="text-gray-600">Ревизии</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="revisions-list">
|
||||||
|
<div class="text-center py-8 text-gray-500">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const configUUID = '{{.ConfigUUID}}';
|
||||||
|
let configData = null;
|
||||||
|
let projectData = null;
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text || '';
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(value) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) return '—';
|
||||||
|
return '$\u00A0' + num.toLocaleString('ru-RU', {minimumFractionDigits: 0, maximumFractionDigits: 2});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersionSnapshot(version) {
|
||||||
|
try {
|
||||||
|
const raw = typeof version.data === 'string' ? version.data : '';
|
||||||
|
if (!raw) return { article: '—', price: null, serverCount: 1 };
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return {
|
||||||
|
article: parsed.article || '—',
|
||||||
|
price: typeof parsed.total_price === 'number' ? parsed.total_price : null,
|
||||||
|
serverCount: Number.isFinite(Number(parsed.server_count)) && Number(parsed.server_count) > 0
|
||||||
|
? Number(parsed.server_count)
|
||||||
|
: 1
|
||||||
|
};
|
||||||
|
} catch (_) {
|
||||||
|
return { article: '—', price: null, serverCount: 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateBreadcrumbSpecName(name) {
|
||||||
|
const maxLength = 16;
|
||||||
|
if (!name || name.length <= maxLength) return name;
|
||||||
|
return name.slice(0, maxLength - 1) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfigInfo() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/configs/' + configUUID);
|
||||||
|
if (!resp.ok) {
|
||||||
|
document.getElementById('revisions-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Конфигурация не найдена</div>';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
configData = await resp.json();
|
||||||
|
|
||||||
|
const fullConfigName = configData.name || 'Конфигурация';
|
||||||
|
const configBreadcrumbEl = document.getElementById('breadcrumb-config');
|
||||||
|
configBreadcrumbEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
||||||
|
configBreadcrumbEl.title = fullConfigName;
|
||||||
|
document.getElementById('breadcrumb-config-link').href = '/configurator?uuid=' + configUUID;
|
||||||
|
|
||||||
|
if (configData.project_uuid) {
|
||||||
|
const projResp = await fetch('/api/projects/' + configData.project_uuid);
|
||||||
|
if (projResp.ok) {
|
||||||
|
projectData = await projResp.json();
|
||||||
|
document.getElementById('breadcrumb-code').textContent = projectData.code || '—';
|
||||||
|
const variant = (projectData.variant || '').trim();
|
||||||
|
document.getElementById('breadcrumb-variant').textContent = variant === '' ? 'main' : variant;
|
||||||
|
document.getElementById('breadcrumb-variant-link').href = '/projects/' + projectData.uuid;
|
||||||
|
|
||||||
|
// Find main variant for code link
|
||||||
|
const allResp = await fetch('/api/projects/all');
|
||||||
|
if (allResp.ok) {
|
||||||
|
const allProjects = await allResp.json();
|
||||||
|
const main = (Array.isArray(allProjects) ? allProjects : []).find(
|
||||||
|
p => p.code === projectData.code && (p.variant || '').trim() === ''
|
||||||
|
);
|
||||||
|
if (main) {
|
||||||
|
document.getElementById('breadcrumb-code-link').href = '/projects/' + main.uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('revisions-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVersions() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/configs/' + configUUID + '/versions?limit=200');
|
||||||
|
if (!resp.ok) {
|
||||||
|
document.getElementById('revisions-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки ревизий</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
const versions = data.versions || [];
|
||||||
|
renderVersions(versions);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('revisions-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVersions(versions) {
|
||||||
|
if (versions.length === 0) {
|
||||||
|
document.getElementById('revisions-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">Нет ревизий</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>';
|
||||||
|
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-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||||
|
html += '</tr></thead><tbody class="divide-y">';
|
||||||
|
|
||||||
|
versions.forEach((v, idx) => {
|
||||||
|
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 += '<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>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(snapshot.article) + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + formatMoney(snapshot.price) + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(String(snapshot.serverCount)) + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
||||||
|
|
||||||
|
// Open in configurator (readonly view)
|
||||||
|
html += '<a href="/configurator?uuid=' + configUUID + '&version=' + v.version_no + '" class="text-blue-600 hover:text-blue-800 inline-block" 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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg></a>';
|
||||||
|
|
||||||
|
// Clone from this version
|
||||||
|
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 += '</td></tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
document.getElementById('revisions-list').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cloneFromVersion(versionNo) {
|
||||||
|
const name = prompt('Название копии:', (configData ? configData.name : '') + ' (v' + versionNo + ')');
|
||||||
|
if (!name) return;
|
||||||
|
const resp = await fetch('/api/configs/' + configUUID + '/clone', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({name: name, from_version: versionNo})
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
alert(data.error || 'Не удалось скопировать');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const created = await resp.json();
|
||||||
|
showToast('Копия создана', 'success');
|
||||||
|
if (created && created.uuid) {
|
||||||
|
window.location.href = '/configurator?uuid=' + created.uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rollbackToVersion(versionNo) {
|
||||||
|
if (!confirm('Восстановить конфигурацию до ревизии v' + versionNo + '?')) return;
|
||||||
|
const resp = await fetch('/api/configs/' + configUUID + '/rollback', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({target_version: versionNo})
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
alert(data.error || 'Не удалось восстановить');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast('Ревизия восстановлена', 'success');
|
||||||
|
loadVersions();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async function() {
|
||||||
|
const ok = await loadConfigInfo();
|
||||||
|
if (!ok) return;
|
||||||
|
await loadVersions();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{template "base" .}}
|
||||||
@@ -4,13 +4,10 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h1 class="text-2xl font-bold">Мои конфигурации</h1>
|
<h1 class="text-2xl font-bold">Мои конфигурации</h1>
|
||||||
|
|
||||||
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div id="action-buttons" class="mt-4">
|
||||||
<button onclick="openCreateModal()" class="py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
<button onclick="openCreateModal()" class="w-full sm:w-auto py-3 px-6 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||||
+ Создать новую конфигурацию
|
+ Создать новую конфигурацию
|
||||||
</button>
|
</button>
|
||||||
<button id="import-configs-btn" onclick="importConfigsFromServer()" class="py-3 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 font-medium">
|
|
||||||
Импорт с сервера
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 inline-flex rounded-lg border border-gray-200 overflow-hidden">
|
<div class="mt-4 inline-flex rounded-lg border border-gray-200 overflow-hidden">
|
||||||
@@ -57,15 +54,15 @@
|
|||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Номер Opportunity</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Название конфигурации</label>
|
||||||
<input type="text" id="opportunity-number" placeholder="Например: OPP-2024-001"
|
<input type="text" id="opportunity-number" placeholder="Например: Сервер для проекта X"
|
||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
|
||||||
<input id="create-project-input"
|
<input id="create-project-input"
|
||||||
list="create-project-options"
|
list="create-project-options"
|
||||||
placeholder="Начните вводить название проекта"
|
placeholder="Например: OPS-123 (Lenovo)"
|
||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
<datalist id="create-project-options"></datalist>
|
<datalist id="create-project-options"></datalist>
|
||||||
<div class="mt-2 flex justify-between items-center gap-3">
|
<div class="mt-2 flex justify-between items-center gap-3">
|
||||||
@@ -150,7 +147,7 @@
|
|||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
|
||||||
<input id="move-project-input"
|
<input id="move-project-input"
|
||||||
list="move-project-options"
|
list="move-project-options"
|
||||||
placeholder="Начните вводить название проекта"
|
placeholder="Например: OPS-123 (Lenovo)"
|
||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
<datalist id="move-project-options"></datalist>
|
<datalist id="move-project-options"></datalist>
|
||||||
<div class="mt-2 flex justify-between items-center gap-3">
|
<div class="mt-2 flex justify-between items-center gap-3">
|
||||||
@@ -177,7 +174,17 @@
|
|||||||
<div id="create-project-on-move-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
<div id="create-project-on-move-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||||
<h2 class="text-xl font-semibold mb-3">Проект не найден</h2>
|
<h2 class="text-xl font-semibold mb-3">Проект не найден</h2>
|
||||||
<p class="text-sm text-gray-600 mb-4">Проект "<span id="create-project-on-move-name" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать квоту?</span></p>
|
<p class="text-sm text-gray-600 mb-4">Проект с кодом "<span id="create-project-on-move-code" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать квоту?</span></p>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="create-project-on-move-name" class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
|
||||||
|
<input id="create-project-on-move-name" type="text" placeholder="Например: Инфраструктура для OPS-123"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="create-project-on-move-variant" class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
|
||||||
|
<input id="create-project-on-move-variant" type="text" placeholder="Например: Lenovo"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button onclick="closeCreateProjectOnMoveModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
<button onclick="closeCreateProjectOnMoveModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||||
<button id="create-project-on-move-confirm-btn" onclick="confirmCreateProjectOnMove()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать и привязать</button>
|
<button id="create-project-on-move-confirm-btn" onclick="confirmCreateProjectOnMove()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать и привязать</button>
|
||||||
@@ -194,10 +201,12 @@ let configStatusMode = 'active';
|
|||||||
let configsSearch = '';
|
let configsSearch = '';
|
||||||
let projectsCache = [];
|
let projectsCache = [];
|
||||||
let projectNameByUUID = {};
|
let projectNameByUUID = {};
|
||||||
|
let projectCodeByUUID = {};
|
||||||
|
let projectVariantByUUID = {};
|
||||||
let pendingMoveConfigUUID = '';
|
let pendingMoveConfigUUID = '';
|
||||||
let pendingMoveProjectName = '';
|
let pendingMoveProjectCode = '';
|
||||||
let pendingCreateConfigName = '';
|
let pendingCreateConfigName = '';
|
||||||
let pendingCreateProjectName = '';
|
let pendingCreateProjectCode = '';
|
||||||
|
|
||||||
function renderConfigs(configs) {
|
function renderConfigs(configs) {
|
||||||
const emptyText = configStatusMode === 'archived'
|
const emptyText = configStatusMode === 'archived'
|
||||||
@@ -252,10 +261,23 @@ function renderConfigs(configs) {
|
|||||||
html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(projectName) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(projectName) + '</td>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const article = c.article ? escapeHtml(c.article) : '';
|
||||||
|
const serverModel = c.server_model ? escapeHtml(c.server_model) : '';
|
||||||
|
const subtitle = article || serverModel;
|
||||||
if (configStatusMode === 'archived') {
|
if (configStatusMode === 'archived') {
|
||||||
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">' + escapeHtml(c.name) + '</td>';
|
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">';
|
||||||
|
html += '<div>' + escapeHtml(c.name) + '</div>';
|
||||||
|
if (subtitle) {
|
||||||
|
html += '<div class="text-xs text-gray-500">' + subtitle + '</div>';
|
||||||
|
}
|
||||||
|
html += '</td>';
|
||||||
} else {
|
} else {
|
||||||
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
|
html += '<td class="px-4 py-3 text-sm font-medium">';
|
||||||
|
html += '<a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a>';
|
||||||
|
if (subtitle) {
|
||||||
|
html += '<div class="text-xs text-gray-500">' + subtitle + '</div>';
|
||||||
|
}
|
||||||
|
html += '</td>';
|
||||||
}
|
}
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>';
|
||||||
@@ -297,6 +319,30 @@ function renderConfigs(configs) {
|
|||||||
document.getElementById('configs-list').innerHTML = html;
|
document.getElementById('configs-list').innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function projectDisplayKey(project) {
|
||||||
|
const code = (project.code || '').trim();
|
||||||
|
const variant = (project.variant || '').trim();
|
||||||
|
if (!code) return '';
|
||||||
|
return variant ? (code + ' (' + variant + ')') : code;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findProjectByInput(input) {
|
||||||
|
const trimmed = (input || '').trim().toLowerCase();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
const directMatch = projectsCache.find(p => projectDisplayKey(p).toLowerCase() === trimmed);
|
||||||
|
if (directMatch) return directMatch;
|
||||||
|
|
||||||
|
const codeMatches = projectsCache.filter(p => (p.code || '').toLowerCase() === trimmed);
|
||||||
|
if (codeMatches.length === 1) {
|
||||||
|
return codeMatches[0];
|
||||||
|
}
|
||||||
|
if (codeMatches.length > 1) {
|
||||||
|
alert('У проекта несколько вариантов. Укажите вариант в формате "CODE (variant)".');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
@@ -434,17 +480,21 @@ async function createConfig() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectName = document.getElementById('create-project-input').value.trim();
|
const projectCode = document.getElementById('create-project-input').value.trim();
|
||||||
let projectUUID = '';
|
let projectUUID = '';
|
||||||
|
|
||||||
if (projectName) {
|
if (projectCode) {
|
||||||
const existingProject = projectsCache.find(p => p.is_active && p.name.toLowerCase() === projectName.toLowerCase());
|
const matchedProject = findProjectByInput(projectCode);
|
||||||
if (existingProject) {
|
if (matchedProject) {
|
||||||
projectUUID = existingProject.uuid;
|
if (!matchedProject.is_active) {
|
||||||
|
alert('Проект с таким кодом находится в архиве. Восстановите его или выберите другой.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
projectUUID = matchedProject.uuid;
|
||||||
} else {
|
} else {
|
||||||
pendingCreateConfigName = name;
|
pendingCreateConfigName = name;
|
||||||
pendingCreateProjectName = projectName;
|
pendingCreateProjectCode = projectCode;
|
||||||
openCreateProjectOnCreateModal(projectName);
|
openCreateProjectOnCreateModal(projectCode);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -492,12 +542,14 @@ function openMoveProjectModal(uuid, configName, currentProjectUUID) {
|
|||||||
projectsCache.forEach(project => {
|
projectsCache.forEach(project => {
|
||||||
if (!project.is_active) return;
|
if (!project.is_active) return;
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = project.name;
|
option.value = projectDisplayKey(project);
|
||||||
|
option.label = project.name || '';
|
||||||
options.appendChild(option);
|
options.appendChild(option);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (currentProjectUUID && projectNameByUUID[currentProjectUUID]) {
|
if (currentProjectUUID && projectCodeByUUID[currentProjectUUID]) {
|
||||||
input.value = projectNameByUUID[currentProjectUUID];
|
const variant = projectVariantByUUID[currentProjectUUID] || '';
|
||||||
|
input.value = variant ? (projectCodeByUUID[currentProjectUUID] + ' (' + variant + ')') : projectCodeByUUID[currentProjectUUID];
|
||||||
} else {
|
} else {
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
@@ -513,19 +565,23 @@ function closeMoveProjectModal() {
|
|||||||
|
|
||||||
async function confirmMoveProject() {
|
async function confirmMoveProject() {
|
||||||
const uuid = document.getElementById('move-project-uuid').value;
|
const uuid = document.getElementById('move-project-uuid').value;
|
||||||
const projectName = document.getElementById('move-project-input').value.trim();
|
const projectCode = document.getElementById('move-project-input').value.trim();
|
||||||
|
|
||||||
if (!uuid) return;
|
if (!uuid) return;
|
||||||
let projectUUID = '';
|
let projectUUID = '';
|
||||||
|
|
||||||
if (projectName) {
|
if (projectCode) {
|
||||||
const existingProject = projectsCache.find(p => p.is_active && p.name.toLowerCase() === projectName.toLowerCase());
|
const matchedProject = findProjectByInput(projectCode);
|
||||||
if (existingProject) {
|
if (matchedProject) {
|
||||||
projectUUID = existingProject.uuid;
|
if (!matchedProject.is_active) {
|
||||||
|
alert('Проект с таким кодом находится в архиве. Восстановите его или выберите другой.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
projectUUID = matchedProject.uuid;
|
||||||
} else {
|
} else {
|
||||||
pendingMoveConfigUUID = uuid;
|
pendingMoveConfigUUID = uuid;
|
||||||
pendingMoveProjectName = projectName;
|
pendingMoveProjectCode = projectCode;
|
||||||
openCreateProjectOnMoveModal(projectName);
|
openCreateProjectOnMoveModal(projectCode);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -542,7 +598,9 @@ function clearCreateProjectInput() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreateProjectOnMoveModal(projectName) {
|
function openCreateProjectOnMoveModal(projectName) {
|
||||||
document.getElementById('create-project-on-move-name').textContent = projectName;
|
document.getElementById('create-project-on-move-code').textContent = projectName;
|
||||||
|
document.getElementById('create-project-on-move-name').value = projectName;
|
||||||
|
document.getElementById('create-project-on-move-variant').value = '';
|
||||||
document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать квоту?';
|
document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать квоту?';
|
||||||
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и привязать';
|
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и привязать';
|
||||||
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
||||||
@@ -550,7 +608,9 @@ function openCreateProjectOnMoveModal(projectName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreateProjectOnCreateModal(projectName) {
|
function openCreateProjectOnCreateModal(projectName) {
|
||||||
document.getElementById('create-project-on-move-name').textContent = projectName;
|
document.getElementById('create-project-on-move-code').textContent = projectName;
|
||||||
|
document.getElementById('create-project-on-move-name').value = projectName;
|
||||||
|
document.getElementById('create-project-on-move-variant').value = '';
|
||||||
document.getElementById('create-project-on-move-description').textContent = 'Создать и использовать для новой конфигурации?';
|
document.getElementById('create-project-on-move-description').textContent = 'Создать и использовать для новой конфигурации?';
|
||||||
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и использовать';
|
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и использовать';
|
||||||
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
||||||
@@ -561,22 +621,32 @@ function closeCreateProjectOnMoveModal() {
|
|||||||
document.getElementById('create-project-on-move-modal').classList.add('hidden');
|
document.getElementById('create-project-on-move-modal').classList.add('hidden');
|
||||||
document.getElementById('create-project-on-move-modal').classList.remove('flex');
|
document.getElementById('create-project-on-move-modal').classList.remove('flex');
|
||||||
pendingMoveConfigUUID = '';
|
pendingMoveConfigUUID = '';
|
||||||
pendingMoveProjectName = '';
|
pendingMoveProjectCode = '';
|
||||||
pendingCreateConfigName = '';
|
pendingCreateConfigName = '';
|
||||||
pendingCreateProjectName = '';
|
pendingCreateProjectCode = '';
|
||||||
|
document.getElementById('create-project-on-move-name').value = '';
|
||||||
|
document.getElementById('create-project-on-move-variant').value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmCreateProjectOnMove() {
|
async function confirmCreateProjectOnMove() {
|
||||||
if (pendingCreateConfigName && pendingCreateProjectName) {
|
const projectNameInput = document.getElementById('create-project-on-move-name');
|
||||||
|
const projectVariantInput = document.getElementById('create-project-on-move-variant');
|
||||||
|
const projectName = (projectNameInput.value || '').trim();
|
||||||
|
const projectVariant = (projectVariantInput.value || '').trim();
|
||||||
|
if (pendingCreateConfigName && pendingCreateProjectCode) {
|
||||||
const configName = pendingCreateConfigName;
|
const configName = pendingCreateConfigName;
|
||||||
const projectName = pendingCreateProjectName;
|
const projectCode = pendingCreateProjectCode;
|
||||||
try {
|
try {
|
||||||
const createResp = await fetch('/api/projects', {
|
const createResp = await fetch('/api/projects', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({ name: projectName })
|
body: JSON.stringify({ name: projectName, code: projectCode, variant: projectVariant })
|
||||||
});
|
});
|
||||||
if (!createResp.ok) {
|
if (!createResp.ok) {
|
||||||
|
if (createResp.status === 409) {
|
||||||
|
alert('Проект с таким кодом и вариантом уже существует');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const err = await createResp.json();
|
const err = await createResp.json();
|
||||||
alert('Не удалось создать проект: ' + (err.error || 'ошибка'));
|
alert('Не удалось создать проект: ' + (err.error || 'ошибка'));
|
||||||
return;
|
return;
|
||||||
@@ -584,14 +654,14 @@ async function confirmCreateProjectOnMove() {
|
|||||||
|
|
||||||
const newProject = await createResp.json();
|
const newProject = await createResp.json();
|
||||||
pendingCreateConfigName = '';
|
pendingCreateConfigName = '';
|
||||||
pendingCreateProjectName = '';
|
pendingCreateProjectCode = '';
|
||||||
await loadProjectsForConfigUI();
|
await loadProjectsForConfigUI();
|
||||||
const created = await createConfigWithProject(configName, newProject.uuid);
|
const created = await createConfigWithProject(configName, newProject.uuid);
|
||||||
if (created) {
|
if (created) {
|
||||||
closeCreateProjectOnMoveModal();
|
closeCreateProjectOnMoveModal();
|
||||||
} else {
|
} else {
|
||||||
closeCreateProjectOnMoveModal();
|
closeCreateProjectOnMoveModal();
|
||||||
document.getElementById('create-project-input').value = projectName;
|
document.getElementById('create-project-input').value = projectCode;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Ошибка создания проекта');
|
alert('Ошибка создания проекта');
|
||||||
@@ -600,8 +670,8 @@ async function confirmCreateProjectOnMove() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const configUUID = pendingMoveConfigUUID;
|
const configUUID = pendingMoveConfigUUID;
|
||||||
const projectName = pendingMoveProjectName;
|
const projectCode = pendingMoveProjectCode;
|
||||||
if (!configUUID || !projectName) {
|
if (!configUUID || !projectCode) {
|
||||||
closeCreateProjectOnMoveModal();
|
closeCreateProjectOnMoveModal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -610,9 +680,13 @@ async function confirmCreateProjectOnMove() {
|
|||||||
const createResp = await fetch('/api/projects', {
|
const createResp = await fetch('/api/projects', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({ name: projectName })
|
body: JSON.stringify({ name: projectName, code: projectCode, variant: projectVariant })
|
||||||
});
|
});
|
||||||
if (!createResp.ok) {
|
if (!createResp.ok) {
|
||||||
|
if (createResp.status === 409) {
|
||||||
|
alert('Проект с таким кодом и вариантом уже существует');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const err = await createResp.json();
|
const err = await createResp.json();
|
||||||
alert('Не удалось создать проект: ' + (err.error || 'ошибка'));
|
alert('Не удалось создать проект: ' + (err.error || 'ошибка'));
|
||||||
return;
|
return;
|
||||||
@@ -620,9 +694,9 @@ async function confirmCreateProjectOnMove() {
|
|||||||
|
|
||||||
const newProject = await createResp.json();
|
const newProject = await createResp.json();
|
||||||
pendingMoveConfigUUID = '';
|
pendingMoveConfigUUID = '';
|
||||||
pendingMoveProjectName = '';
|
pendingMoveProjectCode = '';
|
||||||
await loadProjectsForConfigUI();
|
await loadProjectsForConfigUI();
|
||||||
document.getElementById('move-project-input').value = projectName;
|
document.getElementById('move-project-input').value = projectCode;
|
||||||
const moved = await moveConfigToProject(configUUID, newProject.uuid);
|
const moved = await moveConfigToProject(configUUID, newProject.uuid);
|
||||||
if (moved) {
|
if (moved) {
|
||||||
closeCreateProjectOnMoveModal();
|
closeCreateProjectOnMoveModal();
|
||||||
@@ -785,44 +859,19 @@ async function loadConfigs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importConfigsFromServer() {
|
|
||||||
const button = document.getElementById('import-configs-btn');
|
|
||||||
const originalText = button.textContent;
|
|
||||||
button.disabled = true;
|
|
||||||
button.textContent = 'Импорт...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/configs/import', { method: 'POST' });
|
|
||||||
const data = await resp.json();
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
|
||||||
alert('Ошибка импорта: ' + (data.error || 'неизвестная ошибка'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
alert(
|
|
||||||
'Импорт завершен:\n' +
|
|
||||||
'- Новых: ' + (data.imported || 0) + '\n' +
|
|
||||||
'- Обновлено: ' + (data.updated || 0) + '\n' +
|
|
||||||
'- Пропущено (локальные изменения): ' + (data.skipped || 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
currentPage = 1;
|
|
||||||
await loadConfigs();
|
|
||||||
} catch (e) {
|
|
||||||
alert('Ошибка импорта с сервера');
|
|
||||||
} finally {
|
|
||||||
button.disabled = false;
|
|
||||||
button.textContent = originalText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
applyStatusModeUI();
|
applyStatusModeUI();
|
||||||
loadProjectsForConfigUI().then(loadConfigs);
|
loadProjectsForConfigUI().then(loadConfigs);
|
||||||
|
|
||||||
// Load latest pricelist version for badge
|
// Load latest pricelist version for badge
|
||||||
loadLatestPricelistVersion();
|
loadLatestPricelistVersion();
|
||||||
|
|
||||||
|
// Listen for sync completion events from navbar
|
||||||
|
window.addEventListener('sync-completed', function(e) {
|
||||||
|
// Reset pagination and reload configurations list
|
||||||
|
currentPage = 1;
|
||||||
|
loadConfigs();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('configs-search').addEventListener('input', function(e) {
|
document.getElementById('configs-search').addEventListener('input', function(e) {
|
||||||
@@ -834,6 +883,8 @@ document.getElementById('configs-search').addEventListener('input', function(e)
|
|||||||
async function loadProjectsForConfigUI() {
|
async function loadProjectsForConfigUI() {
|
||||||
projectsCache = [];
|
projectsCache = [];
|
||||||
projectNameByUUID = {};
|
projectNameByUUID = {};
|
||||||
|
projectCodeByUUID = {};
|
||||||
|
projectVariantByUUID = {};
|
||||||
try {
|
try {
|
||||||
// Use /api/projects/all to get all projects without pagination
|
// Use /api/projects/all to get all projects without pagination
|
||||||
const resp = await fetch('/api/projects/all');
|
const resp = await fetch('/api/projects/all');
|
||||||
@@ -846,7 +897,11 @@ async function loadProjectsForConfigUI() {
|
|||||||
projectsCache = allProjects;
|
projectsCache = allProjects;
|
||||||
|
|
||||||
allProjects.forEach(project => {
|
allProjects.forEach(project => {
|
||||||
projectNameByUUID[project.uuid] = project.name;
|
const variant = (project.variant || '').trim();
|
||||||
|
const baseName = project.name || '';
|
||||||
|
projectNameByUUID[project.uuid] = variant ? (baseName + ' (' + variant + ')') : baseName;
|
||||||
|
projectCodeByUUID[project.uuid] = project.code || '';
|
||||||
|
projectVariantByUUID[project.uuid] = project.variant || '';
|
||||||
});
|
});
|
||||||
|
|
||||||
const createOptions = document.getElementById('create-project-options');
|
const createOptions = document.getElementById('create-project-options');
|
||||||
@@ -855,7 +910,8 @@ async function loadProjectsForConfigUI() {
|
|||||||
projectsCache.forEach(project => {
|
projectsCache.forEach(project => {
|
||||||
if (!project.is_active) return;
|
if (!project.is_active) return;
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = project.name;
|
option.value = projectDisplayKey(project);
|
||||||
|
option.label = project.name || '';
|
||||||
createOptions.appendChild(option);
|
createOptions.appendChild(option);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,27 @@
|
|||||||
<!-- Header with config name and back button -->
|
<!-- Header with config name and back button -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<a href="/configs" class="text-gray-500 hover:text-gray-700">
|
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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="M15 19l-7-7 7-7"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<h1 class="text-2xl font-bold">
|
<div class="text-2xl font-bold flex items-center gap-2" id="config-breadcrumbs">
|
||||||
<span id="config-name">Конфигуратор</span>
|
<a id="breadcrumb-project-code-link" href="/projects" class="text-blue-700 hover:underline">
|
||||||
</h1>
|
<span id="breadcrumb-project-code">—</span>
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<a id="breadcrumb-project-variant-link" href="/projects" class="text-blue-700 hover:underline">
|
||||||
|
<span id="breadcrumb-project-variant">main</span>
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<a id="breadcrumb-config-name-link" href="#" class="text-blue-700 hover:underline">
|
||||||
|
<span id="breadcrumb-config-name">Конфигуратор</span>
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<span id="breadcrumb-config-version">v1</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="save-buttons" class="hidden flex items-center space-x-2">
|
<div id="save-buttons" class="hidden flex items-center space-x-2">
|
||||||
<button id="refresh-prices-btn" onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
<button id="refresh-prices-btn" onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||||
@@ -98,6 +111,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div id="cart-summary-content" class="p-4">
|
<div id="cart-summary-content" class="p-4">
|
||||||
|
<div id="article-display" class="text-sm text-gray-700 mb-3 font-mono"></div>
|
||||||
<div id="cart-items" class="space-y-2 mb-4"></div>
|
<div id="cart-items" class="space-y-2 mb-4"></div>
|
||||||
<div class="border-t pt-3 flex justify-between items-center">
|
<div class="border-t pt-3 flex justify-between items-center">
|
||||||
<div class="text-lg font-bold">
|
<div class="text-lg font-bold">
|
||||||
@@ -326,12 +340,94 @@ let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
|||||||
// State
|
// State
|
||||||
let configUUID = '{{.ConfigUUID}}';
|
let configUUID = '{{.ConfigUUID}}';
|
||||||
let configName = '';
|
let configName = '';
|
||||||
|
let currentVersionNo = 1;
|
||||||
|
let projectUUID = '';
|
||||||
|
let projectName = '';
|
||||||
|
let projectCode = '';
|
||||||
|
let projectVariant = '';
|
||||||
|
let projectIndexLoaded = false;
|
||||||
|
let projectByUUID = {};
|
||||||
|
let projectMainByCode = {};
|
||||||
|
|
||||||
|
async function loadProjectIndex() {
|
||||||
|
if (projectIndexLoaded) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/projects/all');
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
const allProjects = Array.isArray(data) ? data : (data.projects || []);
|
||||||
|
projectByUUID = {};
|
||||||
|
projectMainByCode = {};
|
||||||
|
allProjects.forEach(p => {
|
||||||
|
projectByUUID[p.uuid] = p;
|
||||||
|
const code = (p.code || '').trim();
|
||||||
|
const variant = (p.variant || '').trim();
|
||||||
|
if (code && (variant === '' || variant === 'main')) {
|
||||||
|
if (!projectMainByCode[code]) {
|
||||||
|
projectMainByCode[code] = p.uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
projectIndexLoaded = true;
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConfigBreadcrumbs() {
|
||||||
|
const codeEl = document.getElementById('breadcrumb-project-code');
|
||||||
|
const variantEl = document.getElementById('breadcrumb-project-variant');
|
||||||
|
const configEl = document.getElementById('breadcrumb-config-name');
|
||||||
|
const versionEl = document.getElementById('breadcrumb-config-version');
|
||||||
|
const projectCodeLinkEl = document.getElementById('breadcrumb-project-code-link');
|
||||||
|
const projectVariantLinkEl = document.getElementById('breadcrumb-project-variant-link');
|
||||||
|
|
||||||
|
let code = 'Без проекта';
|
||||||
|
let variant = 'main';
|
||||||
|
if (projectUUID && projectByUUID[projectUUID]) {
|
||||||
|
code = (projectByUUID[projectUUID].code || '').trim() || 'Без проекта';
|
||||||
|
const rawVariant = (projectByUUID[projectUUID].variant || '').trim();
|
||||||
|
variant = rawVariant === '' ? 'main' : rawVariant;
|
||||||
|
if (projectCodeLinkEl) {
|
||||||
|
const mainUUID = projectMainByCode[code];
|
||||||
|
projectCodeLinkEl.href = mainUUID ? ('/projects/' + mainUUID) : ('/projects/' + projectUUID);
|
||||||
|
}
|
||||||
|
if (projectVariantLinkEl) {
|
||||||
|
projectVariantLinkEl.href = '/projects/' + projectUUID;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (projectCodeLinkEl) projectCodeLinkEl.href = '/projects';
|
||||||
|
if (projectVariantLinkEl) projectVariantLinkEl.href = '/projects';
|
||||||
|
}
|
||||||
|
codeEl.textContent = code;
|
||||||
|
variantEl.textContent = variant;
|
||||||
|
const fullConfigName = configName || 'Конфигурация';
|
||||||
|
configEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
||||||
|
configEl.title = fullConfigName;
|
||||||
|
versionEl.textContent = 'v' + (currentVersionNo || 1);
|
||||||
|
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
|
||||||
|
if (configNameLinkEl && configUUID) {
|
||||||
|
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateBreadcrumbSpecName(name) {
|
||||||
|
const maxLength = 16;
|
||||||
|
if (!name || name.length <= maxLength) return name;
|
||||||
|
return name.slice(0, maxLength - 1) + '…';
|
||||||
|
}
|
||||||
let currentTab = 'base';
|
let currentTab = 'base';
|
||||||
let allComponents = [];
|
let allComponents = [];
|
||||||
let cart = [];
|
let cart = [];
|
||||||
let categoryOrderMap = {}; // Category code -> display_order mapping
|
let categoryOrderMap = {}; // Category code -> display_order mapping
|
||||||
let autoSaveTimeout = null; // Timeout for debounced autosave
|
let autoSaveTimeout = null; // Timeout for debounced autosave
|
||||||
|
let hasUnsavedChanges = false;
|
||||||
|
let exitSaveStarted = false;
|
||||||
let serverCount = 1; // Server count for the configuration
|
let serverCount = 1; // Server count for the configuration
|
||||||
|
let serverModelForQuote = '';
|
||||||
|
let supportCode = '';
|
||||||
|
let currentArticle = '';
|
||||||
|
let articlePreviewTimeout = null;
|
||||||
let selectedPricelistIds = {
|
let selectedPricelistIds = {
|
||||||
estimate: null,
|
estimate: null,
|
||||||
warehouse: null,
|
warehouse: null,
|
||||||
@@ -609,7 +705,10 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
|
|
||||||
const config = await resp.json();
|
const config = await resp.json();
|
||||||
configName = config.name;
|
configName = config.name;
|
||||||
document.getElementById('config-name').textContent = config.name;
|
currentVersionNo = config.current_version_no || 1;
|
||||||
|
projectUUID = config.project_uuid || '';
|
||||||
|
await loadProjectIndex();
|
||||||
|
updateConfigBreadcrumbs();
|
||||||
document.getElementById('save-buttons').classList.remove('hidden');
|
document.getElementById('save-buttons').classList.remove('hidden');
|
||||||
|
|
||||||
// Set server count from config
|
// Set server count from config
|
||||||
@@ -631,6 +730,9 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
category: item.category || getCategoryFromLotName(item.lot_name)
|
category: item.category || getCategoryFromLotName(item.lot_name)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
serverModelForQuote = config.server_model || '';
|
||||||
|
supportCode = config.support_code || '';
|
||||||
|
currentArticle = config.article || '';
|
||||||
|
|
||||||
// Restore custom price if saved
|
// Restore custom price if saved
|
||||||
if (config.custom_price) {
|
if (config.custom_price) {
|
||||||
@@ -945,7 +1047,32 @@ function renderTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSingleSelectTab(categories) {
|
function renderSingleSelectTab(categories) {
|
||||||
let html = `
|
let html = '';
|
||||||
|
if (currentTab === 'base') {
|
||||||
|
html += `
|
||||||
|
<div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
||||||
|
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель сервера для КП:</label>
|
||||||
|
<label for="support-code-select" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
||||||
|
<input type="text"
|
||||||
|
id="server-model-input"
|
||||||
|
value="${escapeHtml(serverModelForQuote)}"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||||
|
oninput="updateServerModelForQuote(this.value)">
|
||||||
|
<select id="support-code-select"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||||
|
onchange="updateSupportCode(this.value)">
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="1yW" ${supportCode === '1yW' ? 'selected' : ''}>1yW</option>
|
||||||
|
<option value="1yB" ${supportCode === '1yB' ? 'selected' : ''}>1yB</option>
|
||||||
|
<option value="1yS" ${supportCode === '1yS' ? 'selected' : ''}>1yS</option>
|
||||||
|
<option value="1yP" ${supportCode === '1yP' ? 'selected' : ''}>1yP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
html += `
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -1633,6 +1760,8 @@ function updateCartUI() {
|
|||||||
calculateCustomPrice();
|
calculateCustomPrice();
|
||||||
renderSalePriceTable();
|
renderSalePriceTable();
|
||||||
|
|
||||||
|
scheduleArticlePreview();
|
||||||
|
|
||||||
if (cart.length === 0) {
|
if (cart.length === 0) {
|
||||||
document.getElementById('cart-items').innerHTML =
|
document.getElementById('cart-items').innerHTML =
|
||||||
'<div class="text-gray-500 text-center py-2">Конфигурация пуста</div>';
|
'<div class="text-gray-500 text-center py-2">Конфигурация пуста</div>';
|
||||||
@@ -1708,13 +1837,191 @@ function escapeHtml(text) {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateServerModelForQuote(value) {
|
||||||
|
serverModelForQuote = value || '';
|
||||||
|
scheduleArticlePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSupportCode(value) {
|
||||||
|
supportCode = value || '';
|
||||||
|
scheduleArticlePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleArticlePreview() {
|
||||||
|
if (articlePreviewTimeout) {
|
||||||
|
clearTimeout(articlePreviewTimeout);
|
||||||
|
}
|
||||||
|
articlePreviewTimeout = setTimeout(() => {
|
||||||
|
previewArticle();
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewArticle() {
|
||||||
|
const el = document.getElementById('article-display');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const model = serverModelForQuote.trim();
|
||||||
|
if (!model || !selectedPricelistIds.estimate || cart.length === 0) {
|
||||||
|
currentArticle = '';
|
||||||
|
el.textContent = 'Артикул: —';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/configs/preview-article', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
server_model: serverModelForQuote,
|
||||||
|
support_code: supportCode,
|
||||||
|
pricelist_id: selectedPricelistIds.estimate,
|
||||||
|
items: cart.map(item => ({
|
||||||
|
lot_name: item.lot_name,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit_price: item.unit_price || 0
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
currentArticle = '';
|
||||||
|
el.textContent = 'Артикул: —';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
currentArticle = data.article || '';
|
||||||
|
el.textContent = currentArticle ? ('Артикул: ' + currentArticle) : 'Артикул: —';
|
||||||
|
} catch(e) {
|
||||||
|
currentArticle = '';
|
||||||
|
el.textContent = 'Артикул: —';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentArticle() {
|
||||||
|
return currentArticle || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutosaveStorageKey() {
|
||||||
|
return `qf_config_autosave_${configUUID || 'default'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSavePayload() {
|
||||||
|
const customPriceInput = document.getElementById('custom-price-input');
|
||||||
|
const customPriceValue = parseFloat(customPriceInput.value);
|
||||||
|
const customPrice = customPriceValue > 0 ? customPriceValue : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: configName,
|
||||||
|
items: cart,
|
||||||
|
custom_price: customPrice,
|
||||||
|
notes: '',
|
||||||
|
server_count: serverCount,
|
||||||
|
server_model: serverModelForQuote,
|
||||||
|
support_code: supportCode,
|
||||||
|
article: getCurrentArticle(),
|
||||||
|
pricelist_id: selectedPricelistIds.estimate,
|
||||||
|
only_in_stock: onlyInStock
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistAutosaveDraft() {
|
||||||
|
if (!configUUID) return;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(getAutosaveStorageKey(), JSON.stringify({
|
||||||
|
payload: buildSavePayload(),
|
||||||
|
saved_at: Date.now()
|
||||||
|
}));
|
||||||
|
} catch (_) {
|
||||||
|
// ignore storage failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAutosaveDraft() {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(getAutosaveStorageKey());
|
||||||
|
} catch (_) {
|
||||||
|
// ignore storage failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreAutosaveDraftIfAny() {
|
||||||
|
if (!configUUID) return;
|
||||||
|
let raw = null;
|
||||||
|
try {
|
||||||
|
raw = sessionStorage.getItem(getAutosaveStorageKey());
|
||||||
|
} catch (_) {
|
||||||
|
raw = null;
|
||||||
|
}
|
||||||
|
if (!raw) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const payload = parsed && parsed.payload ? parsed.payload : null;
|
||||||
|
if (!payload) return;
|
||||||
|
|
||||||
|
if (Array.isArray(payload.items)) {
|
||||||
|
cart = payload.items.map(item => ({
|
||||||
|
lot_name: item.lot_name,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit_price: item.unit_price,
|
||||||
|
estimate_price: item.unit_price,
|
||||||
|
warehouse_price: null,
|
||||||
|
competitor_price: null,
|
||||||
|
description: item.description || '',
|
||||||
|
category: item.category || getCategoryFromLotName(item.lot_name)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (typeof payload.server_count === 'number' && payload.server_count > 0) {
|
||||||
|
serverCount = payload.server_count;
|
||||||
|
const serverCountInput = document.getElementById('server-count');
|
||||||
|
if (serverCountInput) serverCountInput.value = serverCount;
|
||||||
|
const totalServerCount = document.getElementById('total-server-count');
|
||||||
|
if (totalServerCount) totalServerCount.textContent = serverCount;
|
||||||
|
}
|
||||||
|
serverModelForQuote = payload.server_model || serverModelForQuote;
|
||||||
|
supportCode = payload.support_code || supportCode;
|
||||||
|
currentArticle = payload.article || currentArticle;
|
||||||
|
selectedPricelistIds.estimate = payload.pricelist_id || selectedPricelistIds.estimate;
|
||||||
|
onlyInStock = Boolean(payload.only_in_stock);
|
||||||
|
|
||||||
|
const customPriceInput = document.getElementById('custom-price-input');
|
||||||
|
if (customPriceInput) {
|
||||||
|
if (typeof payload.custom_price === 'number' && payload.custom_price > 0) {
|
||||||
|
customPriceInput.value = payload.custom_price.toFixed(2);
|
||||||
|
} else {
|
||||||
|
customPriceInput.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasUnsavedChanges = true;
|
||||||
|
} catch (_) {
|
||||||
|
// ignore invalid draft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConfigOnExit() {
|
||||||
|
if (!configUUID || !hasUnsavedChanges || exitSaveStarted) return;
|
||||||
|
exitSaveStarted = true;
|
||||||
|
try {
|
||||||
|
fetch('/api/configs/' + configUUID, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(buildSavePayload()),
|
||||||
|
keepalive: true
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// best effort save on page exit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function triggerAutoSave() {
|
function triggerAutoSave() {
|
||||||
// Debounce autosave - wait 1 second after last change
|
// Autosave keeps local draft only; server revision is created on Save/Exit.
|
||||||
|
hasUnsavedChanges = true;
|
||||||
if (autoSaveTimeout) {
|
if (autoSaveTimeout) {
|
||||||
clearTimeout(autoSaveTimeout);
|
clearTimeout(autoSaveTimeout);
|
||||||
}
|
}
|
||||||
autoSaveTimeout = setTimeout(() => {
|
autoSaveTimeout = setTimeout(() => {
|
||||||
saveConfig(false); // false = don't show notification
|
persistAutosaveDraft();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1728,29 +2035,13 @@ async function saveConfig(showNotification = true) {
|
|||||||
|
|
||||||
await refreshPriceLevels({ force: true, noCache: true });
|
await refreshPriceLevels({ force: true, noCache: true });
|
||||||
|
|
||||||
// Get custom price if set
|
|
||||||
const customPriceInput = document.getElementById('custom-price-input');
|
|
||||||
const customPriceValue = parseFloat(customPriceInput.value);
|
|
||||||
const customPrice = customPriceValue > 0 ? customPriceValue : null;
|
|
||||||
|
|
||||||
// Get server count
|
|
||||||
const serverCountValue = serverCount;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/configs/' + configUUID, {
|
const resp = await fetch('/api/configs/' + configUUID, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(buildSavePayload())
|
||||||
name: configName,
|
|
||||||
items: cart,
|
|
||||||
custom_price: customPrice,
|
|
||||||
notes: '',
|
|
||||||
server_count: serverCountValue,
|
|
||||||
pricelist_id: selectedPricelistIds.estimate,
|
|
||||||
only_in_stock: onlyInStock
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
@@ -1760,6 +2051,16 @@ async function saveConfig(showNotification = true) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
hasUnsavedChanges = false;
|
||||||
|
clearAutosaveDraft();
|
||||||
|
exitSaveStarted = false;
|
||||||
|
|
||||||
if (showNotification) {
|
if (showNotification) {
|
||||||
showToast('Сохранено', 'success');
|
showToast('Сохранено', 'success');
|
||||||
}
|
}
|
||||||
@@ -1792,17 +2093,19 @@ async function exportCSV() {
|
|||||||
...item,
|
...item,
|
||||||
unit_price: getDisplayPrice(item),
|
unit_price: getDisplayPrice(item),
|
||||||
}));
|
}));
|
||||||
|
const article = getCurrentArticle();
|
||||||
const resp = await fetch('/api/export/csv', {
|
const resp = await fetch('/api/export/csv', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({items: exportItems, name: configName})
|
body: JSON.stringify({items: exportItems, name: configName, project_uuid: projectUUID, article: article, server_count: serverCount, pricelist_id: selectedPricelistIds.estimate || null})
|
||||||
});
|
});
|
||||||
|
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = getFilenameFromResponse(resp) || (configName || 'config') + '.csv';
|
const articleForName = article || 'BOM';
|
||||||
|
a.download = getFilenameFromResponse(resp) || ((configName || 'config') + ' ' + articleForName + '.csv');
|
||||||
a.click();
|
a.click();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
@@ -2048,7 +2351,7 @@ async function exportCSVWithCustomPrice() {
|
|||||||
const resp = await fetch('/api/export/csv', {
|
const resp = await fetch('/api/export/csv', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({items: adjustedCart, name: configName})
|
body: JSON.stringify({items: adjustedCart, name: configName, project_uuid: projectUUID})
|
||||||
});
|
});
|
||||||
|
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<a href="/pricelists" class="text-gray-500 hover:text-gray-700">
|
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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="M15 19l-7-7 7-7"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<h1 id="page-title" class="text-2xl font-bold text-gray-900">Загрузка...</h1>
|
<h1 id="page-title" class="text-2xl font-bold text-gray-900">Загрузка...</h1>
|
||||||
|
|||||||
@@ -235,6 +235,12 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
checkPricelistWritePermission();
|
checkPricelistWritePermission();
|
||||||
loadPricelists(1);
|
loadPricelists(1);
|
||||||
|
|
||||||
|
// Listen for sync completion events from navbar
|
||||||
|
window.addEventListener('sync-completed', function(e) {
|
||||||
|
// Reload pricelists on sync completion
|
||||||
|
loadPricelists(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -4,22 +4,51 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Назад к проектам">
|
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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="M15 19l-7-7 7-7"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<h1 class="text-2xl font-bold" id="project-title">Проект</h1>
|
<div class="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<a id="project-code-link" href="/projects" class="text-blue-700 hover:underline">
|
||||||
|
<span id="project-code">—</span>
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<div class="relative">
|
||||||
|
<button id="project-variant-button" type="button" class="inline-flex items-center gap-2 text-base font-medium px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-200">
|
||||||
|
<span id="project-variant-label">main</span>
|
||||||
|
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="project-variant-menu" class="absolute left-0 mt-2 min-w-[10rem] rounded-lg border border-gray-200 bg-white shadow-lg hidden z-10">
|
||||||
|
<div id="project-variant-list" class="py-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-6 gap-3">
|
||||||
<button onclick="openCreateModal()" class="py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
<button onclick="openNewVariantModal()" class="py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium">
|
||||||
|
+ Новый вариант
|
||||||
|
</button>
|
||||||
|
<button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||||
+ Создать новую квоту
|
+ Создать новую квоту
|
||||||
</button>
|
</button>
|
||||||
<button onclick="openImportModal()" class="py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium">
|
<button onclick="openImportModal()" class="py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium">
|
||||||
Импорт квоты
|
Импорт квоты
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="openProjectSettingsModal()" class="py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium">
|
||||||
|
Параметры
|
||||||
|
</button>
|
||||||
|
<button onclick="exportProject()" class="py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium">
|
||||||
|
Экспорт CSV
|
||||||
|
</button>
|
||||||
|
<button id="delete-variant-btn" onclick="deleteVariant()" class="py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium hidden">
|
||||||
|
Удалить вариант
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<a id="tracker-link" href="https://tracker.yandex.ru/OPS-1933" target="_blank" rel="noopener noreferrer" class="text-sm text-blue-600 hover:text-blue-800 hover:underline">
|
<a id="tracker-link" href="https://tracker.yandex.ru/OPS-1933" target="_blank" rel="noopener noreferrer" class="text-sm text-blue-600 hover:text-blue-800 hover:underline">
|
||||||
@@ -58,38 +87,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="rename-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
<div id="new-variant-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg p-6">
|
||||||
<h2 class="text-xl font-semibold mb-4">Переименовать квоту</h2>
|
<h2 class="text-xl font-semibold mb-4">Новый вариант</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Новое название</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
|
||||||
<input type="text" id="rename-input"
|
<div id="new-variant-code" class="px-3 py-2 bg-gray-50 border rounded text-sm text-gray-700">—</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="new-variant-name" class="block text-sm font-medium text-gray-700 mb-1">Название (необязательно)</label>
|
||||||
|
<input id="new-variant-name" type="text" placeholder="Например: Lenovo"
|
||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
<input type="hidden" id="rename-uuid">
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="new-variant-value" class="block text-sm font-medium text-gray-700 mb-1">Вариант</label>
|
||||||
|
<input id="new-variant-value" type="text" placeholder="Например: Lenovo"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<div class="text-xs text-gray-500 mt-1">Оставьте пустым для main нельзя — нужно уникальное значение.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3 mt-6">
|
<div class="mt-6 flex justify-end gap-2">
|
||||||
<button onclick="closeRenameModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
<button onclick="closeNewVariantModal()" class="px-4 py-2 text-gray-700 bg-gray-100 rounded hover:bg-gray-200">Отмена</button>
|
||||||
<button onclick="renameConfig()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
|
<button onclick="createNewVariant()" class="px-4 py-2 text-white bg-purple-600 rounded hover:bg-purple-700">Создать</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="clone-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
<div id="config-action-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||||
<h2 class="text-xl font-semibold mb-4">Копировать квоту</h2>
|
<h2 class="text-xl font-semibold mb-4">Действия с квотой</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Название копии</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Название</label>
|
||||||
<input type="text" id="clone-input"
|
<input type="text" id="config-action-name"
|
||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
<input type="hidden" id="clone-uuid">
|
|
||||||
</div>
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||||
|
<input type="checkbox" id="config-action-copy" class="rounded border-gray-300">
|
||||||
|
Создать копию
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
|
||||||
|
<input id="config-action-project-input"
|
||||||
|
list="config-action-project-options"
|
||||||
|
placeholder="Начните вводить проект"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<datalist id="config-action-project-options"></datalist>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Вариант</label>
|
||||||
|
<input id="config-action-variant-input"
|
||||||
|
list="config-action-variant-options"
|
||||||
|
placeholder="Выберите вариант"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<datalist id="config-action-variant-options"></datalist>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="config-action-uuid">
|
||||||
|
<input type="hidden" id="config-action-current-name">
|
||||||
|
<input type="hidden" id="config-action-current-project">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3 mt-6">
|
<div class="flex justify-end space-x-3 mt-6">
|
||||||
<button onclick="closeCloneModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
<button onclick="closeConfigActionModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||||
<button onclick="cloneConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">Копировать</button>
|
<button onclick="saveConfigAction()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,11 +173,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="project-settings-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Параметры проекта</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
|
||||||
|
<input type="text" id="project-settings-code"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
|
||||||
|
<input type="text" id="project-settings-variant"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
|
||||||
|
<input type="text" id="project-settings-name"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Ссылка для "открыть в трекере"</label>
|
||||||
|
<input type="text" id="project-settings-tracker-url" placeholder="https://tracker.example.com/PROJ-123"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<div class="text-xs text-gray-500 mt-1">Оставьте пустым, чтобы скрыть ссылку.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-3 mt-6">
|
||||||
|
<button onclick="closeProjectSettingsModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||||
|
<button onclick="saveProjectSettings()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const projectUUID = '{{.ProjectUUID}}';
|
const projectUUID = '{{.ProjectUUID}}';
|
||||||
let configStatusMode = 'active';
|
let configStatusMode = 'active';
|
||||||
let project = null;
|
let project = null;
|
||||||
let allConfigs = [];
|
let allConfigs = [];
|
||||||
|
let projectVariants = [];
|
||||||
|
let projectsCatalog = [];
|
||||||
|
let variantMenuInitialized = false;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
@@ -131,6 +227,95 @@ function resolveProjectTrackerURL(projectData) {
|
|||||||
return explicitURL;
|
return explicitURL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatProjectTitle(projectData) {
|
||||||
|
if (!projectData) return 'Проект';
|
||||||
|
const code = (projectData.code || '').trim();
|
||||||
|
const name = (projectData.name || '').trim();
|
||||||
|
const variant = (projectData.variant || '').trim();
|
||||||
|
if (!code) return name || 'Проект';
|
||||||
|
if (variant) {
|
||||||
|
return code + ': (' + variant + ') ' + (name || '');
|
||||||
|
}
|
||||||
|
return code + ': ' + (name || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVariantLabel(variant) {
|
||||||
|
const trimmed = (variant || '').trim();
|
||||||
|
return trimmed === '' ? 'main' : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVariantsForCode(code) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/projects/all');
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
const allProjects = Array.isArray(data) ? data : (data.projects || []);
|
||||||
|
projectsCatalog = allProjects.filter(p => p && p.uuid && p.is_active !== false);
|
||||||
|
if (!code) {
|
||||||
|
projectVariants = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
projectVariants = allProjects
|
||||||
|
.filter(p => (p.code || '').trim() === code && p.is_active !== false)
|
||||||
|
.map(p => ({uuid: p.uuid, variant: (p.variant || '').trim()}));
|
||||||
|
projectVariants.sort((a, b) => normalizeVariantLabel(a.variant).localeCompare(normalizeVariantLabel(b.variant)));
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVariantSelect() {
|
||||||
|
const list = document.getElementById('project-variant-list');
|
||||||
|
const menu = document.getElementById('project-variant-menu');
|
||||||
|
const button = document.getElementById('project-variant-button');
|
||||||
|
const label = document.getElementById('project-variant-label');
|
||||||
|
const codeLink = document.getElementById('project-code-link');
|
||||||
|
if (!list || !menu || !button || !label) return;
|
||||||
|
list.innerHTML = '';
|
||||||
|
const variants = projectVariants.length ? projectVariants : [{uuid: projectUUID, variant: (project && project.variant) || ''}];
|
||||||
|
let mainUUID = '';
|
||||||
|
variants.forEach(item => {
|
||||||
|
const variantLabel = normalizeVariantLabel(item.variant);
|
||||||
|
if (variantLabel === 'main' && !mainUUID) {
|
||||||
|
mainUUID = item.uuid;
|
||||||
|
}
|
||||||
|
const option = document.createElement('button');
|
||||||
|
option.type = 'button';
|
||||||
|
option.className = 'w-full text-left px-3 py-2 text-sm hover:bg-gray-50';
|
||||||
|
if (item.uuid === projectUUID) {
|
||||||
|
option.className += ' font-semibold text-gray-900';
|
||||||
|
label.textContent = variantLabel;
|
||||||
|
}
|
||||||
|
option.textContent = variantLabel;
|
||||||
|
option.onclick = function() {
|
||||||
|
menu.classList.add('hidden');
|
||||||
|
if (item.uuid && item.uuid !== projectUUID) {
|
||||||
|
window.location.href = '/projects/' + item.uuid;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
list.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (codeLink) {
|
||||||
|
const targetMain = mainUUID || projectUUID;
|
||||||
|
codeLink.href = '/projects/' + targetMain;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!variantMenuInitialized) {
|
||||||
|
button.onclick = function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
menu.classList.toggle('hidden');
|
||||||
|
};
|
||||||
|
document.addEventListener('click', function() {
|
||||||
|
menu.classList.add('hidden');
|
||||||
|
});
|
||||||
|
menu.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
variantMenuInitialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setConfigStatusMode(mode) {
|
function setConfigStatusMode(mode) {
|
||||||
if (mode !== 'active' && mode !== 'archived') return;
|
if (mode !== 'active' && mode !== 'archived') return;
|
||||||
configStatusMode = mode;
|
configStatusMode = mode;
|
||||||
@@ -171,6 +356,7 @@ function renderConfigs(configs) {
|
|||||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</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-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
|
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
|
||||||
|
html += '<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">Ревизия</th>';
|
||||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
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">';
|
html += '</tr></thead><tbody class="divide-y">';
|
||||||
|
|
||||||
@@ -191,17 +377,23 @@ function renderConfigs(configs) {
|
|||||||
}
|
}
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-500">$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500">$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
|
if (configStatusMode === 'archived') {
|
||||||
html += '<td class="px-4 py-3 text-sm text-right">$' + total.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
|
||||||
|
} else {
|
||||||
|
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 + '">$' + total.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||||
|
const versionNo = c.current_version_no || 1;
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-center text-gray-500">v' + versionNo + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
||||||
if (configStatusMode === 'archived') {
|
if (configStatusMode === 'archived') {
|
||||||
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';
|
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-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="M5 13l4 4L19 7"></path></svg></button>';
|
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="M5 13l4 4L19 7"></path></svg></button>';
|
||||||
} else {
|
} else {
|
||||||
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
|
html += '<a href="/configs/' + c.uuid + '/revisions" class="text-purple-600 hover:text-purple-800 inline-block" 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>';
|
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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg></a>';
|
||||||
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
|
html += '<button onclick="openConfigActionModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\', \'' + (c.project_uuid || projectUUID) + '\')" class="text-indigo-600 hover:text-indigo-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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg></button>';
|
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="M4 7h16M4 12h16M4 17h16"></path></svg></button>';
|
||||||
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="В архив">';
|
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg></button>';
|
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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg></button>';
|
||||||
}
|
}
|
||||||
@@ -213,7 +405,8 @@ function renderConfigs(configs) {
|
|||||||
html += '<tr>';
|
html += '<tr>';
|
||||||
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700" colspan="4">Итого по проекту</td>';
|
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700" colspan="4">Итого по проекту</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-700">' + configs.length + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-700">' + configs.length + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-right font-semibold text-gray-900">$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-right font-semibold text-gray-900" data-footer-total="1">$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||||
|
html += '<td class="px-4 py-3"></td>';
|
||||||
html += '<td class="px-4 py-3"></td>';
|
html += '<td class="px-4 py-3"></td>';
|
||||||
html += '</tr>';
|
html += '</tr>';
|
||||||
html += '</tfoot>';
|
html += '</tfoot>';
|
||||||
@@ -228,7 +421,9 @@ async function loadProject() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
project = await resp.json();
|
project = await resp.json();
|
||||||
document.getElementById('project-title').textContent = project.name;
|
document.getElementById('project-code').textContent = project.code || '—';
|
||||||
|
await loadVariantsForCode(project.code || '');
|
||||||
|
renderVariantSelect();
|
||||||
const trackerLink = document.getElementById('tracker-link');
|
const trackerLink = document.getElementById('tracker-link');
|
||||||
if (trackerLink) {
|
if (trackerLink) {
|
||||||
if (project && project.is_system) {
|
if (project && project.is_system) {
|
||||||
@@ -271,6 +466,56 @@ function openCreateModal() {
|
|||||||
document.getElementById('create-name').focus();
|
document.getElementById('create-name').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openNewVariantModal() {
|
||||||
|
if (!project) return;
|
||||||
|
document.getElementById('new-variant-code').textContent = (project.code || '').trim() || '—';
|
||||||
|
document.getElementById('new-variant-name').value = project.name || '';
|
||||||
|
document.getElementById('new-variant-value').value = '';
|
||||||
|
document.getElementById('new-variant-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('new-variant-modal').classList.add('flex');
|
||||||
|
document.getElementById('new-variant-value').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeNewVariantModal() {
|
||||||
|
document.getElementById('new-variant-modal').classList.add('hidden');
|
||||||
|
document.getElementById('new-variant-modal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNewVariant() {
|
||||||
|
if (!project) return;
|
||||||
|
const code = (project.code || '').trim();
|
||||||
|
const variant = (document.getElementById('new-variant-value').value || '').trim();
|
||||||
|
const nameRaw = (document.getElementById('new-variant-name').value || '').trim();
|
||||||
|
if (!code || !variant) {
|
||||||
|
showToast('Укажите вариант', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
code: code,
|
||||||
|
variant: variant,
|
||||||
|
name: nameRaw ? nameRaw : null
|
||||||
|
};
|
||||||
|
const resp = await fetch('/api/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
showToast(data.error || 'Ошибка создания варианта', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const created = await resp.json().catch(() => null);
|
||||||
|
closeNewVariantModal();
|
||||||
|
showToast('Вариант создан', 'success');
|
||||||
|
if (created && created.uuid) {
|
||||||
|
window.location.href = '/projects/' + created.uuid;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadProject();
|
||||||
|
loadConfigs();
|
||||||
|
}
|
||||||
|
|
||||||
function closeCreateModal() {
|
function closeCreateModal() {
|
||||||
document.getElementById('create-modal').classList.add('hidden');
|
document.getElementById('create-modal').classList.add('hidden');
|
||||||
document.getElementById('create-modal').classList.remove('flex');
|
document.getElementById('create-modal').classList.remove('flex');
|
||||||
@@ -318,68 +563,227 @@ async function reactivateConfig(uuid) {
|
|||||||
loadConfigs();
|
loadConfigs();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRenameModal(uuid, currentName) {
|
function projectCodeEntries() {
|
||||||
document.getElementById('rename-uuid').value = uuid;
|
const byCode = new Map();
|
||||||
document.getElementById('rename-input').value = currentName;
|
projectsCatalog.forEach(p => {
|
||||||
document.getElementById('rename-modal').classList.remove('hidden');
|
const code = (p.code || '').trim();
|
||||||
document.getElementById('rename-modal').classList.add('flex');
|
if (!code || byCode.has(code)) return;
|
||||||
}
|
byCode.set(code, {
|
||||||
|
code: code,
|
||||||
function closeRenameModal() {
|
name: (p.name || '').trim()
|
||||||
document.getElementById('rename-modal').classList.add('hidden');
|
});
|
||||||
document.getElementById('rename-modal').classList.remove('flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renameConfig() {
|
|
||||||
const uuid = document.getElementById('rename-uuid').value;
|
|
||||||
const name = document.getElementById('rename-input').value.trim();
|
|
||||||
if (!name) {
|
|
||||||
alert('Введите название');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resp = await fetch('/api/configs/' + uuid + '/rename', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({name: name})
|
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
return Array.from(byCode.values()).sort((a, b) => a.code.localeCompare(b.code, 'ru'));
|
||||||
alert('Не удалось переименовать');
|
}
|
||||||
return;
|
|
||||||
|
function formatProjectAutocompleteValue(entry) {
|
||||||
|
if (!entry) return '';
|
||||||
|
return entry.name ? (entry.code + ' - ' + entry.name) : entry.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProjectCodeFromInput(rawInput) {
|
||||||
|
const input = (rawInput || '').trim();
|
||||||
|
if (!input) return '';
|
||||||
|
const entries = projectCodeEntries();
|
||||||
|
|
||||||
|
const exactCode = entries.find(e => e.code.toLowerCase() === input.toLowerCase());
|
||||||
|
if (exactCode) return exactCode.code;
|
||||||
|
|
||||||
|
const exactDisplayMatches = entries.filter(e => formatProjectAutocompleteValue(e).toLowerCase() === input.toLowerCase());
|
||||||
|
if (exactDisplayMatches.length === 1) return exactDisplayMatches[0].code;
|
||||||
|
|
||||||
|
const byUniqueName = entries.filter(e => (e.name || '').toLowerCase() === input.toLowerCase());
|
||||||
|
if (byUniqueName.length === 1) return byUniqueName[0].code;
|
||||||
|
|
||||||
|
if (input.includes(' - ')) {
|
||||||
|
const codeCandidate = input.split(' - ')[0].trim();
|
||||||
|
const byCandidate = entries.find(e => e.code.toLowerCase() === codeCandidate.toLowerCase());
|
||||||
|
if (byCandidate) return byCandidate.code;
|
||||||
}
|
}
|
||||||
closeRenameModal();
|
|
||||||
loadConfigs();
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCloneModal(uuid, currentName) {
|
function populateProjectAutocomplete() {
|
||||||
document.getElementById('clone-uuid').value = uuid;
|
const options = document.getElementById('config-action-project-options');
|
||||||
document.getElementById('clone-input').value = currentName + ' (копия)';
|
options.innerHTML = '';
|
||||||
document.getElementById('clone-modal').classList.remove('hidden');
|
projectCodeEntries().forEach(entry => {
|
||||||
document.getElementById('clone-modal').classList.add('flex');
|
const opt = document.createElement('option');
|
||||||
}
|
opt.value = formatProjectAutocompleteValue(entry);
|
||||||
|
opt.label = entry.code;
|
||||||
function closeCloneModal() {
|
options.appendChild(opt);
|
||||||
document.getElementById('clone-modal').classList.add('hidden');
|
|
||||||
document.getElementById('clone-modal').classList.remove('flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cloneConfig() {
|
|
||||||
const uuid = document.getElementById('clone-uuid').value;
|
|
||||||
const name = document.getElementById('clone-input').value.trim();
|
|
||||||
if (!name) {
|
|
||||||
alert('Введите название');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resp = await fetch('/api/projects/' + projectUUID + '/configs/' + uuid + '/clone', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({name: name})
|
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
}
|
||||||
alert('Не удалось скопировать');
|
|
||||||
|
function variantsForProjectCode(projectCode) {
|
||||||
|
const code = (projectCode || '').trim();
|
||||||
|
if (!code) return [];
|
||||||
|
return projectsCatalog
|
||||||
|
.filter(p => (p.code || '').trim() === code)
|
||||||
|
.map(p => ({uuid: p.uuid, variant: normalizeVariantLabel(p.variant || '')}))
|
||||||
|
.sort((a, b) => a.variant.localeCompare(b.variant, 'ru'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateVariantAutocomplete(projectCode, selectedVariantLabel) {
|
||||||
|
const options = document.getElementById('config-action-variant-options');
|
||||||
|
const input = document.getElementById('config-action-variant-input');
|
||||||
|
const variants = variantsForProjectCode(projectCode);
|
||||||
|
options.innerHTML = '';
|
||||||
|
variants.forEach(v => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = v.variant;
|
||||||
|
options.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (selectedVariantLabel) {
|
||||||
|
input.value = selectedVariantLabel;
|
||||||
|
} else if (variants.length === 1) {
|
||||||
|
input.value = variants[0].variant;
|
||||||
|
} else {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTargetProjectUUIDFromInputs() {
|
||||||
|
const projectCode = resolveProjectCodeFromInput(document.getElementById('config-action-project-input').value);
|
||||||
|
if (!projectCode) {
|
||||||
|
return {error: 'Выберите проект из подсказок'};
|
||||||
|
}
|
||||||
|
const variantLabel = normalizeVariantLabel(document.getElementById('config-action-variant-input').value || 'main');
|
||||||
|
const target = projectsCatalog.find(p =>
|
||||||
|
(p.code || '').trim() === projectCode &&
|
||||||
|
normalizeVariantLabel(p.variant || '') === variantLabel
|
||||||
|
);
|
||||||
|
if (!target) {
|
||||||
|
return {error: 'Выберите вариант из подсказок'};
|
||||||
|
}
|
||||||
|
return {uuid: target.uuid};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncActionModalMode() {
|
||||||
|
const copyCheckbox = document.getElementById('config-action-copy');
|
||||||
|
if (copyCheckbox.checked) {
|
||||||
|
// no-op: copy always uses latest revision
|
||||||
|
} else {
|
||||||
|
// no-op: copy always uses latest revision
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openConfigActionModal(uuid, currentName, currentProjectUUID) {
|
||||||
|
document.getElementById('config-action-uuid').value = uuid;
|
||||||
|
document.getElementById('config-action-current-name').value = currentName;
|
||||||
|
document.getElementById('config-action-current-project').value = currentProjectUUID || projectUUID;
|
||||||
|
document.getElementById('config-action-name').value = currentName;
|
||||||
|
document.getElementById('config-action-copy').checked = false;
|
||||||
|
populateProjectAutocomplete();
|
||||||
|
const currentProject = projectsCatalog.find(p => p.uuid === (currentProjectUUID || projectUUID));
|
||||||
|
if (currentProject) {
|
||||||
|
const entry = {
|
||||||
|
code: (currentProject.code || '').trim(),
|
||||||
|
name: (currentProject.name || '').trim()
|
||||||
|
};
|
||||||
|
document.getElementById('config-action-project-input').value = formatProjectAutocompleteValue(entry);
|
||||||
|
populateVariantAutocomplete(entry.code, normalizeVariantLabel(currentProject.variant || ''));
|
||||||
|
} else {
|
||||||
|
document.getElementById('config-action-project-input').value = '';
|
||||||
|
populateVariantAutocomplete('', '');
|
||||||
|
}
|
||||||
|
syncActionModalMode();
|
||||||
|
document.getElementById('config-action-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('config-action-modal').classList.add('flex');
|
||||||
|
const nameInput = document.getElementById('config-action-name');
|
||||||
|
nameInput.focus();
|
||||||
|
nameInput.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfigActionModal() {
|
||||||
|
document.getElementById('config-action-modal').classList.add('hidden');
|
||||||
|
document.getElementById('config-action-modal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfigAction() {
|
||||||
|
const notify = (message, type) => {
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(message, type || 'success');
|
||||||
|
} else {
|
||||||
|
alert(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uuid = document.getElementById('config-action-uuid').value;
|
||||||
|
const currentName = document.getElementById('config-action-current-name').value;
|
||||||
|
const currentProjectUUID = document.getElementById('config-action-current-project').value || projectUUID;
|
||||||
|
const name = document.getElementById('config-action-name').value.trim();
|
||||||
|
const copy = document.getElementById('config-action-copy').checked;
|
||||||
|
const targetProject = resolveTargetProjectUUIDFromInputs();
|
||||||
|
if (targetProject.error) {
|
||||||
|
notify(targetProject.error, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closeCloneModal();
|
const targetProjectUUID = targetProject.uuid || currentProjectUUID;
|
||||||
loadConfigs();
|
|
||||||
|
if (!name) {
|
||||||
|
notify('Введите название', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copy) {
|
||||||
|
const cloneResp = await fetch('/api/projects/' + targetProjectUUID + '/configs/' + uuid + '/clone', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({name: name})
|
||||||
|
});
|
||||||
|
if (!cloneResp.ok) {
|
||||||
|
notify('Не удалось скопировать квоту', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeConfigActionModal();
|
||||||
|
await loadConfigs();
|
||||||
|
notify('Копия создана', 'success');
|
||||||
|
if (targetProjectUUID && targetProjectUUID !== projectUUID) {
|
||||||
|
window.location.href = '/projects/' + targetProjectUUID;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
if (name !== currentName) {
|
||||||
|
const renameResp = await fetch('/api/configs/' + uuid + '/rename', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({name: name})
|
||||||
|
});
|
||||||
|
if (!renameResp.ok) {
|
||||||
|
notify('Не удалось переименовать квоту', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetProjectUUID !== currentProjectUUID) {
|
||||||
|
const moveResp = await fetch('/api/configs/' + uuid + '/project', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({project_uuid: targetProjectUUID})
|
||||||
|
});
|
||||||
|
if (!moveResp.ok) {
|
||||||
|
notify('Не удалось перенести квоту', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
closeConfigActionModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeConfigActionModal();
|
||||||
|
await loadConfigs();
|
||||||
|
notify('Изменения сохранены', 'success');
|
||||||
|
if (targetProjectUUID && targetProjectUUID !== projectUUID) {
|
||||||
|
window.location.href = '/projects/' + targetProjectUUID;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openImportModal() {
|
function openImportModal() {
|
||||||
@@ -397,6 +801,65 @@ function closeImportModal() {
|
|||||||
document.getElementById('import-modal').classList.remove('flex');
|
document.getElementById('import-modal').classList.remove('flex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openProjectSettingsModal() {
|
||||||
|
if (!project) return;
|
||||||
|
if (project.is_system) {
|
||||||
|
alert('Системный проект нельзя редактировать');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('project-settings-code').value = project.code || '';
|
||||||
|
document.getElementById('project-settings-variant').value = project.variant || '';
|
||||||
|
document.getElementById('project-settings-name').value = project.name || '';
|
||||||
|
document.getElementById('project-settings-tracker-url').value = (project.tracker_url || '').trim();
|
||||||
|
document.getElementById('project-settings-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('project-settings-modal').classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeProjectSettingsModal() {
|
||||||
|
document.getElementById('project-settings-modal').classList.add('hidden');
|
||||||
|
document.getElementById('project-settings-modal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProjectSettings() {
|
||||||
|
if (!project) return;
|
||||||
|
const code = document.getElementById('project-settings-code').value.trim();
|
||||||
|
const variant = document.getElementById('project-settings-variant').value.trim();
|
||||||
|
const name = document.getElementById('project-settings-name').value.trim();
|
||||||
|
const trackerURL = document.getElementById('project-settings-tracker-url').value.trim();
|
||||||
|
if (!code) {
|
||||||
|
alert('Введите код проекта');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resp = await fetch('/api/projects/' + projectUUID, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({code: code, variant: variant, name: name, tracker_url: trackerURL})
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
if (resp.status === 409) {
|
||||||
|
alert('Проект с таким кодом и вариантом уже существует');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert('Не удалось сохранить параметры проекта');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
project = await resp.json();
|
||||||
|
document.getElementById('project-code').textContent = project.code || '—';
|
||||||
|
await loadVariantsForCode(project.code || '');
|
||||||
|
renderVariantSelect();
|
||||||
|
const trackerLink = document.getElementById('tracker-link');
|
||||||
|
if (trackerLink) {
|
||||||
|
const trackerURLResolved = resolveProjectTrackerURL(project);
|
||||||
|
if (trackerURLResolved) {
|
||||||
|
trackerLink.href = trackerURLResolved;
|
||||||
|
trackerLink.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
trackerLink.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeProjectSettingsModal();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadImportOptions() {
|
async function loadImportOptions() {
|
||||||
const resp = await fetch('/api/configs?page=1&per_page=500&status=active');
|
const resp = await fetch('/api/configs?page=1&per_page=500&status=active');
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
@@ -476,23 +939,120 @@ function wildcardMatch(value, pattern) {
|
|||||||
return regex.test(value);
|
return regex.test(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteVariant() {
|
||||||
|
if (!project) return;
|
||||||
|
const variantLabel = normalizeVariantLabel(project.variant);
|
||||||
|
if (!confirm('Удалить вариант «' + variantLabel + '»? Все квоты будут архивированы.')) return;
|
||||||
|
const resp = await fetch('/api/projects/' + projectUUID, {method: 'DELETE'});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
alert(data.error || 'Не удалось удалить вариант');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Redirect to main variant or projects list
|
||||||
|
const mainVariant = projectVariants.find(v => normalizeVariantLabel(v.variant) === 'main');
|
||||||
|
if (mainVariant && mainVariant.uuid !== projectUUID) {
|
||||||
|
window.location.href = '/projects/' + mainVariant.uuid;
|
||||||
|
} else {
|
||||||
|
window.location.href = '/projects';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDeleteVariantButton() {
|
||||||
|
const btn = document.getElementById('delete-variant-btn');
|
||||||
|
if (!btn || !project) return;
|
||||||
|
if ((project.variant || '').trim() !== '') {
|
||||||
|
btn.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
btn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
|
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
|
||||||
document.getElementById('rename-modal').addEventListener('click', function(e) { if (e.target === this) closeRenameModal(); });
|
document.getElementById('new-variant-modal').addEventListener('click', function(e) { if (e.target === this) closeNewVariantModal(); });
|
||||||
document.getElementById('clone-modal').addEventListener('click', function(e) { if (e.target === this) closeCloneModal(); });
|
document.getElementById('config-action-modal').addEventListener('click', function(e) { if (e.target === this) closeConfigActionModal(); });
|
||||||
document.getElementById('import-modal').addEventListener('click', function(e) { if (e.target === this) closeImportModal(); });
|
document.getElementById('import-modal').addEventListener('click', function(e) { if (e.target === this) closeImportModal(); });
|
||||||
|
document.getElementById('project-settings-modal').addEventListener('click', function(e) { if (e.target === this) closeProjectSettingsModal(); });
|
||||||
|
document.getElementById('config-action-project-input').addEventListener('input', function(e) {
|
||||||
|
const code = resolveProjectCodeFromInput(e.target.value);
|
||||||
|
populateVariantAutocomplete(code, '');
|
||||||
|
});
|
||||||
|
document.getElementById('config-action-copy').addEventListener('change', function(e) {
|
||||||
|
const currentName = document.getElementById('config-action-current-name').value;
|
||||||
|
const nameInput = document.getElementById('config-action-name');
|
||||||
|
if (e.target.checked && nameInput.value.trim() === currentName.trim()) {
|
||||||
|
nameInput.value = currentName + ' (копия)';
|
||||||
|
}
|
||||||
|
syncActionModalMode();
|
||||||
|
});
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
closeCreateModal();
|
closeCreateModal();
|
||||||
closeRenameModal();
|
closeConfigActionModal();
|
||||||
closeCloneModal();
|
|
||||||
closeImportModal();
|
closeImportModal();
|
||||||
|
closeProjectSettingsModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function updateConfigServerCount(input) {
|
||||||
|
const uuid = input.dataset.uuid;
|
||||||
|
const prevValue = parseInt(input.dataset.prev) || 1;
|
||||||
|
const newValue = parseInt(input.value);
|
||||||
|
if (!newValue || newValue < 1) {
|
||||||
|
input.value = prevValue;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/configs/' + uuid + '/server-count', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({server_count: newValue})
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
input.value = prevValue;
|
||||||
|
showToast('Не удалось обновить количество', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = await resp.json();
|
||||||
|
input.dataset.prev = newValue;
|
||||||
|
// Update row total price
|
||||||
|
const totalCell = document.querySelector('[data-total-uuid="' + uuid + '"]');
|
||||||
|
if (totalCell && updated.total_price != null) {
|
||||||
|
totalCell.textContent = '$' + updated.total_price.toLocaleString('en-US', {minimumFractionDigits: 2});
|
||||||
|
}
|
||||||
|
// Update the config in allConfigs and recalculate footer total
|
||||||
|
for (let i = 0; i < allConfigs.length; i++) {
|
||||||
|
if (allConfigs[i].uuid === uuid) {
|
||||||
|
allConfigs[i].total_price = updated.total_price;
|
||||||
|
allConfigs[i].server_count = newValue;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateFooterTotal();
|
||||||
|
} catch (e) {
|
||||||
|
input.value = prevValue;
|
||||||
|
showToast('Ошибка сети', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFooterTotal() {
|
||||||
|
let totalSum = 0;
|
||||||
|
allConfigs.forEach(c => { totalSum += (c.total_price || 0); });
|
||||||
|
const footer = document.querySelector('tfoot td[data-footer-total]');
|
||||||
|
if (footer) {
|
||||||
|
footer.textContent = '$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportProject() {
|
||||||
|
window.location.href = '/api/projects/' + projectUUID + '/export';
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async function() {
|
document.addEventListener('DOMContentLoaded', async function() {
|
||||||
applyStatusModeUI();
|
applyStatusModeUI();
|
||||||
const ok = await loadProject();
|
const ok = await loadProject();
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
updateDeleteVariantButton();
|
||||||
await loadConfigs();
|
await loadConfigs();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="max-w-md">
|
<div class="max-w-md">
|
||||||
<input id="projects-search" type="text" placeholder="Поиск проекта по названию"
|
<input id="projects-search" type="text" placeholder="Поиск проекта по названию или коду"
|
||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -31,11 +31,21 @@
|
|||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||||
<h2 class="text-xl font-semibold mb-4">Новый проект</h2>
|
<h2 class="text-xl font-semibold mb-4">Новый проект</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="create-project-name" class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
|
||||||
|
<input id="create-project-name" type="text" placeholder="Например: Инфраструктура для OPS-123"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="create-project-code" class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
|
<label for="create-project-code" class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
|
||||||
<input id="create-project-code" type="text" placeholder="Например: OPS-123"
|
<input id="create-project-code" type="text" placeholder="Например: OPS-123"
|
||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="create-project-variant" class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
|
||||||
|
<input id="create-project-variant" type="text" placeholder="Например: Lenovo"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="create-project-tracker-url" class="block text-sm font-medium text-gray-700 mb-1">Ссылка на трекер</label>
|
<label for="create-project-tracker-url" class="block text-sm font-medium text-gray-700 mb-1">Ссылка на трекер</label>
|
||||||
<input id="create-project-tracker-url" type="url" placeholder="https://tracker.yandex.ru/OPS-123"
|
<input id="create-project-tracker-url" type="url" placeholder="https://tracker.yandex.ru/OPS-123"
|
||||||
@@ -59,6 +69,8 @@ let sortField = 'created_at';
|
|||||||
let sortDir = 'desc';
|
let sortDir = 'desc';
|
||||||
let createProjectTrackerManuallyEdited = false;
|
let createProjectTrackerManuallyEdited = false;
|
||||||
let createProjectLastAutoTrackerURL = '';
|
let createProjectLastAutoTrackerURL = '';
|
||||||
|
let variantsByCode = {};
|
||||||
|
let variantsLoaded = false;
|
||||||
|
|
||||||
const trackerBaseURL = 'https://tracker.yandex.ru/';
|
const trackerBaseURL = 'https://tracker.yandex.ru/';
|
||||||
|
|
||||||
@@ -85,6 +97,55 @@ function formatDateTime(value) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeVariant(variant) {
|
||||||
|
const trimmed = (variant || '').trim();
|
||||||
|
return trimmed === '' ? 'main' : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVariantChips(code, fallbackVariant, fallbackUUID) {
|
||||||
|
const variants = variantsByCode[code || ''] || [];
|
||||||
|
if (!variants.length) {
|
||||||
|
const single = normalizeVariant(fallbackVariant);
|
||||||
|
const href = fallbackUUID ? ('/projects/' + fallbackUUID) : '/projects';
|
||||||
|
return '<a href="' + href + '" class="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-900">' + escapeHtml(single) + '</a>';
|
||||||
|
}
|
||||||
|
return variants.map(v => {
|
||||||
|
const href = v.uuid ? ('/projects/' + v.uuid) : '/projects';
|
||||||
|
return '<a href="' + href + '" class="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-700 hover:bg-gray-200 hover:text-gray-900">' + escapeHtml(v.label) + '</a>';
|
||||||
|
}).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVariantsIndex() {
|
||||||
|
if (variantsLoaded) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/projects/all');
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
const allProjects = Array.isArray(data) ? data : (data.projects || []);
|
||||||
|
variantsByCode = {};
|
||||||
|
allProjects.forEach(p => {
|
||||||
|
const code = (p.code || '').trim();
|
||||||
|
const variant = normalizeVariant(p.variant);
|
||||||
|
if (!variantsByCode[code]) {
|
||||||
|
variantsByCode[code] = [];
|
||||||
|
}
|
||||||
|
if (!variantsByCode[code].some(v => v.label === variant)) {
|
||||||
|
variantsByCode[code].push({label: variant, uuid: p.uuid});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.keys(variantsByCode).forEach(code => {
|
||||||
|
variantsByCode[code].sort((a, b) => {
|
||||||
|
if (a.label === 'main') return -1;
|
||||||
|
if (b.label === 'main') return 1;
|
||||||
|
return a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
variantsLoaded = true;
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSort(field) {
|
function toggleSort(field) {
|
||||||
if (sortField === field) {
|
if (sortField === field) {
|
||||||
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||||
@@ -132,10 +193,33 @@ async function loadProjects() {
|
|||||||
}
|
}
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
rows = data.projects || [];
|
rows = data.projects || [];
|
||||||
|
if (Array.isArray(rows) && rows.length) {
|
||||||
|
const byCode = {};
|
||||||
|
rows.forEach(p => {
|
||||||
|
const codeKey = (p.code || '').trim();
|
||||||
|
if (!codeKey) {
|
||||||
|
const fallbackKey = p.uuid || Math.random().toString(36);
|
||||||
|
byCode[fallbackKey] = p;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const variant = (p.variant || '').trim();
|
||||||
|
if (!byCode[codeKey]) {
|
||||||
|
byCode[codeKey] = p;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = byCode[codeKey];
|
||||||
|
const currentVariant = (current.variant || '').trim();
|
||||||
|
if (currentVariant !== '' && variant === '') {
|
||||||
|
byCode[codeKey] = p;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
rows = Object.values(byCode);
|
||||||
|
}
|
||||||
total = data.total || 0;
|
total = data.total || 0;
|
||||||
totalPages = data.total_pages || 0;
|
totalPages = data.total_pages || 0;
|
||||||
page = data.page || currentPage;
|
page = data.page || currentPage;
|
||||||
currentPage = page;
|
currentPage = page;
|
||||||
|
await loadVariantsIndex();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>';
|
root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>';
|
||||||
return;
|
return;
|
||||||
@@ -144,27 +228,22 @@ async function loadProjects() {
|
|||||||
let html = '<div class="overflow-x-auto"><table class="w-full">';
|
let html = '<div class="overflow-x-auto"><table class="w-full">';
|
||||||
html += '<thead class="bg-gray-50">';
|
html += '<thead class="bg-gray-50">';
|
||||||
html += '<tr>';
|
html += '<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">';
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">';
|
||||||
html += '<button type="button" onclick="toggleSort(\'name\')" class="inline-flex items-center gap-1 hover:text-gray-700">Название проекта';
|
html += '<button type="button" onclick="toggleSort(\'name\')" class="inline-flex items-center gap-1 hover:text-gray-700">Название';
|
||||||
if (sortField === 'name') {
|
if (sortField === 'name') {
|
||||||
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
|
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
|
||||||
}
|
}
|
||||||
html += '</button></th>';
|
html += '</button></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">';
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Изменен @ кто</th>';
|
||||||
html += '<button type="button" onclick="toggleSort(\'created_at\')" class="inline-flex items-center gap-1 hover:text-gray-700">Создан';
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Варианты</th>';
|
||||||
if (sortField === 'created_at') {
|
|
||||||
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
|
|
||||||
}
|
|
||||||
html += '</button></th>';
|
|
||||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Кол-во квот</th>';
|
|
||||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
|
|
||||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||||
html += '</tr>';
|
html += '</tr>';
|
||||||
html += '<tr>';
|
html += '<tr>';
|
||||||
html += '<th class="px-4 py-2"></th>';
|
html += '<th class="px-4 py-2"></th>';
|
||||||
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
|
|
||||||
html += '<th class="px-4 py-2"></th>';
|
html += '<th class="px-4 py-2"></th>';
|
||||||
|
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
|
||||||
html += '<th class="px-4 py-2"></th>';
|
html += '<th class="px-4 py-2"></th>';
|
||||||
html += '<th class="px-4 py-2"></th>';
|
html += '<th class="px-4 py-2"></th>';
|
||||||
html += '<th class="px-4 py-2"></th>';
|
html += '<th class="px-4 py-2"></th>';
|
||||||
@@ -175,21 +254,28 @@ async function loadProjects() {
|
|||||||
html += '<tr><td colspan="6" class="px-4 py-6 text-sm text-gray-500 text-center">Проектов нет</td></tr>';
|
html += '<tr><td colspan="6" class="px-4 py-6 text-sm text-gray-500 text-center">Проектов нет</td></tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
rows.forEach(p => {
|
rows.forEach(p => {
|
||||||
html += '<tr class="hover:bg-gray-50">';
|
html += '<tr class="hover:bg-gray-50">';
|
||||||
html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.name) + '</a></td>';
|
const displayName = p.name || '';
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(p.owner_username || '—') + '</td>';
|
const createdBy = p.owner_username || '—';
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(formatDateTime(p.created_at)) + '</td>';
|
const updatedBy = '—';
|
||||||
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + (p.config_count || 0) + '</td>';
|
const createdLabel = formatDateTime(p.created_at) + ' @ ' + createdBy;
|
||||||
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + formatMoney(p.total) + '</td>';
|
const updatedLabel = formatDateTime(p.updated_at) + ' @ ' + updatedBy;
|
||||||
|
const variantChips = renderVariantChips(p.code, p.variant, p.uuid);
|
||||||
|
html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.code || '—') + '</a></td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(displayName) + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(createdLabel) + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(updatedLabel) + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm">' + variantChips + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">';
|
html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">';
|
||||||
|
|
||||||
if (p.is_active) {
|
if (p.is_active) {
|
||||||
html += '<button onclick="copyProject(\'' + p.uuid + '\', \'' + escapeHtml(p.name).replace(/'/g, "\\'") + '\')" class="text-green-700 hover:text-green-900" title="Копировать">';
|
const safeName = escapeHtml(displayName).replace(/'/g, "\\'");
|
||||||
|
html += '<button onclick="copyProject(' + JSON.stringify(p.uuid) + ', ' + JSON.stringify(displayName) + ')" class="text-green-700 hover:text-green-900" 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>';
|
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>';
|
||||||
html += '</button>';
|
html += '</button>';
|
||||||
|
|
||||||
html += '<button onclick="renameProject(\'' + p.uuid + '\', \'' + escapeHtml(p.name).replace(/'/g, "\\'") + '\')" class="text-blue-700 hover:text-blue-900" title="Переименовать">';
|
html += '<button onclick="renameProject(' + JSON.stringify(p.uuid) + ', ' + JSON.stringify(displayName) + ')" class="text-blue-700 hover:text-blue-900" 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>';
|
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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>';
|
||||||
html += '</button>';
|
html += '</button>';
|
||||||
|
|
||||||
@@ -251,15 +337,19 @@ function buildTrackerURLFromProjectCode(projectCode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreateProjectModal() {
|
function openCreateProjectModal() {
|
||||||
|
const nameInput = document.getElementById('create-project-name');
|
||||||
const codeInput = document.getElementById('create-project-code');
|
const codeInput = document.getElementById('create-project-code');
|
||||||
|
const variantInput = document.getElementById('create-project-variant');
|
||||||
const trackerInput = document.getElementById('create-project-tracker-url');
|
const trackerInput = document.getElementById('create-project-tracker-url');
|
||||||
|
nameInput.value = '';
|
||||||
codeInput.value = '';
|
codeInput.value = '';
|
||||||
|
variantInput.value = '';
|
||||||
trackerInput.value = '';
|
trackerInput.value = '';
|
||||||
createProjectTrackerManuallyEdited = false;
|
createProjectTrackerManuallyEdited = false;
|
||||||
createProjectLastAutoTrackerURL = '';
|
createProjectLastAutoTrackerURL = '';
|
||||||
document.getElementById('create-project-modal').classList.remove('hidden');
|
document.getElementById('create-project-modal').classList.remove('hidden');
|
||||||
document.getElementById('create-project-modal').classList.add('flex');
|
document.getElementById('create-project-modal').classList.add('flex');
|
||||||
codeInput.focus();
|
nameInput.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeCreateProjectModal() {
|
function closeCreateProjectModal() {
|
||||||
@@ -278,10 +368,14 @@ function updateCreateProjectTrackerURL() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createProject() {
|
async function createProject() {
|
||||||
|
const nameInput = document.getElementById('create-project-name');
|
||||||
const codeInput = document.getElementById('create-project-code');
|
const codeInput = document.getElementById('create-project-code');
|
||||||
|
const variantInput = document.getElementById('create-project-variant');
|
||||||
const trackerInput = document.getElementById('create-project-tracker-url');
|
const trackerInput = document.getElementById('create-project-tracker-url');
|
||||||
const name = (codeInput.value || '').trim();
|
const name = (nameInput.value || '').trim();
|
||||||
if (!name) {
|
const code = (codeInput.value || '').trim();
|
||||||
|
const variant = (variantInput.value || '').trim();
|
||||||
|
if (!code) {
|
||||||
alert('Введите код проекта');
|
alert('Введите код проекта');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -290,10 +384,16 @@ async function createProject() {
|
|||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: name,
|
name: name,
|
||||||
|
code: code,
|
||||||
|
variant: variant,
|
||||||
tracker_url: (trackerInput.value || '').trim()
|
tracker_url: (trackerInput.value || '').trim()
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
|
if (resp.status === 409) {
|
||||||
|
alert('Проект с таким кодом и вариантом уже существует');
|
||||||
|
return;
|
||||||
|
}
|
||||||
alert('Не удалось создать проект');
|
alert('Не удалось создать проект');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -310,6 +410,10 @@ async function renameProject(projectUUID, currentName) {
|
|||||||
body: JSON.stringify({name: name.trim()})
|
body: JSON.stringify({name: name.trim()})
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
|
if (resp.status === 409) {
|
||||||
|
alert('Проект с таким названием уже существует');
|
||||||
|
return;
|
||||||
|
}
|
||||||
alert('Не удалось переименовать проект');
|
alert('Не удалось переименовать проект');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -353,13 +457,20 @@ async function addConfigToProject(projectUUID) {
|
|||||||
async function copyProject(projectUUID, projectName) {
|
async function copyProject(projectUUID, projectName) {
|
||||||
const newName = prompt('Название копии проекта', projectName + ' (копия)');
|
const newName = prompt('Название копии проекта', projectName + ' (копия)');
|
||||||
if (!newName || !newName.trim()) return;
|
if (!newName || !newName.trim()) return;
|
||||||
|
const newCode = prompt('Код проекта', '');
|
||||||
|
if (!newCode || !newCode.trim()) return;
|
||||||
|
const newVariant = prompt('Вариант (необязательно)', '');
|
||||||
|
|
||||||
const createResp = await fetch('/api/projects', {
|
const createResp = await fetch('/api/projects', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({name: newName.trim()})
|
body: JSON.stringify({name: newName.trim(), code: newCode.trim(), variant: (newVariant || '').trim()})
|
||||||
});
|
});
|
||||||
if (!createResp.ok) {
|
if (!createResp.ok) {
|
||||||
|
if (createResp.status === 409) {
|
||||||
|
alert('Проект с таким кодом и вариантом уже существует');
|
||||||
|
return;
|
||||||
|
}
|
||||||
alert('Не удалось создать копию проекта');
|
alert('Не удалось создать копию проекта');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -385,40 +496,55 @@ async function copyProject(projectUUID, projectName) {
|
|||||||
loadProjects();
|
loadProjects();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadProjects();
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|
||||||
document.getElementById('projects-search').addEventListener('input', function(e) {
|
|
||||||
projectsSearch = (e.target.value || '').trim();
|
|
||||||
currentPage = 1;
|
|
||||||
loadProjects();
|
loadProjects();
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('create-project-code').addEventListener('input', function() {
|
document.getElementById('projects-search').addEventListener('input', function(e) {
|
||||||
updateCreateProjectTrackerURL();
|
projectsSearch = (e.target.value || '').trim();
|
||||||
});
|
currentPage = 1;
|
||||||
|
loadProjects();
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('create-project-tracker-url').addEventListener('input', function(e) {
|
document.getElementById('create-project-code').addEventListener('input', function() {
|
||||||
createProjectTrackerManuallyEdited = (e.target.value || '').trim() !== createProjectLastAutoTrackerURL;
|
updateCreateProjectTrackerURL();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('create-project-code').addEventListener('keydown', function(e) {
|
document.getElementById('create-project-name').addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
createProject();
|
createProject();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('create-project-tracker-url').addEventListener('keydown', function(e) {
|
document.getElementById('create-project-tracker-url').addEventListener('input', function(e) {
|
||||||
if (e.key === 'Enter') {
|
createProjectTrackerManuallyEdited = (e.target.value || '').trim() !== createProjectLastAutoTrackerURL;
|
||||||
e.preventDefault();
|
});
|
||||||
createProject();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('create-project-modal').addEventListener('click', function(e) {
|
document.getElementById('create-project-code').addEventListener('keydown', function(e) {
|
||||||
if (e.target === this) {
|
if (e.key === 'Enter') {
|
||||||
closeCreateProjectModal();
|
e.preventDefault();
|
||||||
}
|
createProject();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('create-project-tracker-url').addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
createProject();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('create-project-modal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeCreateProjectModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for sync completion events from navbar
|
||||||
|
window.addEventListener('sync-completed', function(e) {
|
||||||
|
// Reset pagination and reload projects list
|
||||||
|
loadProjects();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user