Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b5d57902d | ||
|
|
4e1a46bd71 | ||
|
|
857ec7a0e5 | ||
|
|
01f21fa5ac | ||
|
|
a1edca3be9 | ||
|
|
7fbf813952 | ||
|
|
e58fd35ee4 | ||
|
|
e3559035f7 | ||
|
|
5edffe822b | ||
|
|
99fd80bca7 | ||
|
|
d8edd5d5f0 | ||
|
|
9cb17ee03f | ||
|
|
8f596cec68 | ||
|
|
8fd27d11a7 | ||
|
|
600f842b82 | ||
|
|
acf7c8a4da | ||
|
|
5984a57a8b | ||
|
|
84dda8cf0a | ||
|
|
abeb26d82d | ||
|
|
29edd73744 | ||
|
|
e8d0e28415 | ||
|
|
08feda9af6 | ||
|
|
af79b6f3bf | ||
|
|
bca82f9dc0 |
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,4 +75,7 @@ Network Trash Folder
|
|||||||
Temporary Items
|
Temporary Items
|
||||||
.apdisk
|
.apdisk
|
||||||
|
|
||||||
releases/
|
# Release artifacts (binaries, archives, checksums), but DO track releases/memory/ for changelog
|
||||||
|
releases/*
|
||||||
|
!releases/memory/
|
||||||
|
!releases/memory/**
|
||||||
|
|||||||
@@ -56,6 +56,12 @@
|
|||||||
- `/pricelists/:id`
|
- `/pricelists/:id`
|
||||||
- `/setup`
|
- `/setup`
|
||||||
|
|
||||||
|
## Release Notes & Change Log
|
||||||
|
Release notes are maintained in `releases/memory/` directory organized by version tags (e.g., `v1.2.1.md`).
|
||||||
|
Before working on the codebase, review the most recent release notes to understand recent changes.
|
||||||
|
- Check `releases/memory/` for detailed changelog between tags
|
||||||
|
- Each release file documents commits, breaking changes, and migration notes
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
```bash
|
```bash
|
||||||
# Development
|
# Development
|
||||||
|
|||||||
106
README.md
106
README.md
@@ -105,58 +105,85 @@ go run ./cmd/migrate_ops_projects -apply
|
|||||||
go run ./cmd/migrate_ops_projects -apply -yes
|
go run ./cmd/migrate_ops_projects -apply -yes
|
||||||
```
|
```
|
||||||
|
|
||||||
### Минимальные права БД для пользователя квотаций
|
### Права БД для пользователя приложения
|
||||||
|
|
||||||
Если нужен пользователь, который может работать с конфигурациями, но не может создавать/удалять прайслисты:
|
#### Полный набор прав для обычного пользователя
|
||||||
|
|
||||||
|
Чтобы выдать существующему пользователю все необходимые права (без переоздания):
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- 1) Создать пользователя (если его ещё нет)
|
-- Справочные таблицы (только чтение)
|
||||||
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>';
|
GRANT SELECT ON RFQ_LOG.lot TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_categories TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_pricelists TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO '<DB_USER>'@'%';
|
||||||
|
|
||||||
-- 2) Если пользователь уже существовал, принудительно обновить пароль
|
-- Таблицы конфигураций и проектов (чтение и запись)
|
||||||
ALTER USER 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>';
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO '<DB_USER>'@'%';
|
||||||
|
|
||||||
-- 3) (Опционально, но рекомендуется) удалить дубли пользователя с другими host,
|
-- Таблицы синхронизации (только чтение для миграций, чтение+запись для статуса)
|
||||||
-- чтобы не возникало конфликтов вида user@localhost vs user@'%'
|
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO '<DB_USER>'@'%';
|
||||||
DROP USER IF EXISTS 'quote_user'@'localhost';
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO '<DB_USER>'@'%';
|
||||||
DROP USER IF EXISTS 'quote_user'@'127.0.0.1';
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO '<DB_USER>'@'%';
|
||||||
DROP USER IF EXISTS 'quote_user'@'::1';
|
|
||||||
|
|
||||||
-- 4) Сбросить лишние права
|
|
||||||
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'quote_user'@'%';
|
|
||||||
|
|
||||||
-- 5) Чтение данных для конфигуратора и синка
|
|
||||||
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
|
||||||
|
|
||||||
-- 6) Работа с конфигурациями
|
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
|
||||||
|
|
||||||
|
-- Применить изменения
|
||||||
FLUSH PRIVILEGES;
|
FLUSH PRIVILEGES;
|
||||||
|
|
||||||
SHOW GRANTS FOR 'quote_user'@'%';
|
-- Проверка выданных прав
|
||||||
SHOW CREATE USER 'quote_user'@'%';
|
SHOW GRANTS FOR '<DB_USER>'@'%';
|
||||||
```
|
```
|
||||||
|
|
||||||
Полный набор прав для пользователя квотаций:
|
#### Таблицы и их назначение
|
||||||
|
|
||||||
|
| Таблица | Назначение | Права | Примечание |
|
||||||
|
|---------|-----------|-------|-----------|
|
||||||
|
| `lot` | Справочник компонентов | SELECT | Существующая таблица |
|
||||||
|
| `qt_lot_metadata` | Расширенные данные компонентов | SELECT | Метаданные компонентов |
|
||||||
|
| `qt_categories` | Категории компонентов | SELECT | Справочник |
|
||||||
|
| `qt_pricelists` | Прайслисты | SELECT | Управляется сервером |
|
||||||
|
| `qt_pricelist_items` | Позиции прайслистов | SELECT | Управляется сервером |
|
||||||
|
| `qt_configurations` | Сохранённые конфигурации | SELECT, INSERT, UPDATE | Основная таблица работы |
|
||||||
|
| `qt_projects` | Проекты | SELECT, INSERT, UPDATE | Для группировки конфигураций |
|
||||||
|
| `qt_client_local_migrations` | Справочник миграций БД | SELECT | Только чтение (управляется админом) |
|
||||||
|
| `qt_client_schema_state` | Состояние локальной схемы | SELECT, INSERT, UPDATE | Отслеживание примененных миграций |
|
||||||
|
| `qt_pricelist_sync_status` | Статус синхронизации | SELECT, INSERT, UPDATE | Отслеживание активности синхронизации |
|
||||||
|
|
||||||
|
#### При создании нового пользователя
|
||||||
|
|
||||||
|
Если нужно создать нового пользователя с нуля:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
GRANT USAGE ON *.* TO 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>';
|
-- 1) Создать пользователя
|
||||||
|
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>';
|
||||||
|
|
||||||
|
-- 2) Выдать все необходимые права
|
||||||
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
||||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
||||||
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
||||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
||||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'quote_user'@'%';
|
||||||
|
|
||||||
|
-- 3) Применить изменения
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
|
||||||
|
-- 4) Проверить права
|
||||||
|
SHOW GRANTS FOR 'quote_user'@'%';
|
||||||
```
|
```
|
||||||
|
|
||||||
Важно:
|
#### Важные замечания
|
||||||
- не выдавайте `INSERT/UPDATE/DELETE` на `qt_pricelists` и `qt_pricelist_items`, если пользователь не должен управлять прайслистами;
|
|
||||||
- если видите ошибку `Access denied for user ...@'<ip>'`, проверьте, что не осталось других записей `quote_user@host` кроме `quote_user@'%'`;
|
- **Таблицы синхронизации** должны быть созданы администратором БД один раз. Приложение не требует прав CREATE TABLE.
|
||||||
- после смены DB-настроек через `/setup` приложение перезапускается автоматически и подхватывает нового пользователя.
|
- **Прайслисты** (`qt_pricelists`, `qt_pricelist_items`) — справочные таблицы, управляются сервером, пользователь имеет только SELECT.
|
||||||
|
- **Конфигурации и проекты** — таблицы, в которые пишет само приложение (INSERT, UPDATE при сохранении изменений).
|
||||||
|
- **Таблицы миграций** нужны для синхронизации: приложение читает список миграций и отчитывается о применённых.
|
||||||
|
- Если видите ошибку `Access denied for user ...@'<ip>'`, проверьте наличие конфликтующих записей пользователя с разными хостами (user@localhost vs user@'%').
|
||||||
|
|
||||||
### 4. Импорт метаданных компонентов
|
### 4. Импорт метаданных компонентов
|
||||||
|
|
||||||
@@ -320,9 +347,22 @@ quoteforge/
|
|||||||
│ └── static/ # CSS, JS, изображения
|
│ └── static/ # CSS, JS, изображения
|
||||||
├── migrations/ # SQL миграции
|
├── migrations/ # SQL миграции
|
||||||
├── config.example.yaml # Пример конфигурации
|
├── config.example.yaml # Пример конфигурации
|
||||||
|
├── releases/
|
||||||
|
│ └── memory/ # Changelog между тегами (v1.2.1.md, v1.2.2.md, ...)
|
||||||
└── go.mod
|
└── go.mod
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Releases & Changelog
|
||||||
|
|
||||||
|
Change log между версиями хранится в `releases/memory/` каталоге в файлах вида `v{major}.{minor}.{patch}.md`.
|
||||||
|
|
||||||
|
Каждый файл содержит:
|
||||||
|
- Список коммитов между версиями
|
||||||
|
- Описание изменений и их влияния
|
||||||
|
- Breaking changes и заметки о миграции
|
||||||
|
|
||||||
|
**Перед работой над кодом проверьте последний файл в этой папке, чтобы понять текущее состояние проекта.**
|
||||||
|
|
||||||
## Роли пользователей
|
## Роли пользователей
|
||||||
|
|
||||||
| Роль | Описание |
|
| Роль | Описание |
|
||||||
@@ -406,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
|
||||||
|
}
|
||||||
|
|||||||
202
cmd/qfs/main.go
202
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)
|
||||||
@@ -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, componentService, 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 {
|
||||||
@@ -926,6 +1041,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)
|
||||||
@@ -1152,6 +1284,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
"current_version": currentVersion,
|
"current_version": currentVersion,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
|
||||||
}
|
}
|
||||||
|
|
||||||
projects := api.Group("/projects")
|
projects := api.Group("/projects")
|
||||||
@@ -1164,7 +1298,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
search := strings.ToLower(strings.TrimSpace(c.Query("search")))
|
search := strings.ToLower(strings.TrimSpace(c.Query("search")))
|
||||||
author := strings.ToLower(strings.TrimSpace(c.Query("author")))
|
author := strings.ToLower(strings.TrimSpace(c.Query("author")))
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "10"))
|
// Return all projects by default (set high limit for configs to reference)
|
||||||
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "1000"))
|
||||||
sortField := strings.ToLower(strings.TrimSpace(c.DefaultQuery("sort", "created_at")))
|
sortField := strings.ToLower(strings.TrimSpace(c.DefaultQuery("sort", "created_at")))
|
||||||
sortDir := strings.ToLower(strings.TrimSpace(c.DefaultQuery("dir", "desc")))
|
sortDir := strings.ToLower(strings.TrimSpace(c.DefaultQuery("dir", "desc")))
|
||||||
if status != "active" && status != "archived" && status != "all" {
|
if status != "active" && status != "archived" && status != "all" {
|
||||||
@@ -1204,7 +1339,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) {
|
||||||
@@ -1217,8 +1355,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)
|
||||||
@@ -1231,8 +1369,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
|
||||||
}
|
}
|
||||||
@@ -1291,6 +1429,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,
|
||||||
@@ -1316,19 +1456,55 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return simplified list of all projects (UUID + Name only)
|
||||||
|
type ProjectSimple struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
simplified := make([]ProjectSimple, 0, len(allProjects))
|
||||||
|
for _, p := range allProjects {
|
||||||
|
simplified = append(simplified, ProjectSimple{
|
||||||
|
UUID: p.UUID,
|
||||||
|
Code: p.Code,
|
||||||
|
Variant: p.Variant,
|
||||||
|
Name: derefString(p.Name),
|
||||||
|
IsActive: p.IsActive,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
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
|
||||||
}
|
}
|
||||||
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)
|
||||||
@@ -1356,13 +1532,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):
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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 {
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
|||||||
Category: lc.Category,
|
Category: lc.Category,
|
||||||
CategoryName: lc.Category,
|
CategoryName: lc.Category,
|
||||||
Model: lc.Model,
|
Model: lc.Model,
|
||||||
CurrentPrice: lc.CurrentPrice,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +86,6 @@ func (h *ComponentHandler) Get(c *gin.Context) {
|
|||||||
Category: component.Category,
|
Category: component.Category,
|
||||||
CategoryName: component.Category,
|
CategoryName: component.Category,
|
||||||
Model: component.Model,
|
Model: component.Model,
|
||||||
CurrentPrice: component.CurrentPrice,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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"
|
||||||
@@ -14,23 +15,29 @@ type ExportHandler struct {
|
|||||||
exportService *services.ExportService
|
exportService *services.ExportService
|
||||||
configService services.ConfigurationGetter
|
configService services.ConfigurationGetter
|
||||||
componentService *services.ComponentService
|
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,
|
componentService *services.ComponentService,
|
||||||
|
projectService *services.ProjectService,
|
||||||
) *ExportHandler {
|
) *ExportHandler {
|
||||||
return &ExportHandler{
|
return &ExportHandler{
|
||||||
exportService: exportService,
|
exportService: exportService,
|
||||||
configService: configService,
|
configService: configService,
|
||||||
componentService: componentService,
|
componentService: componentService,
|
||||||
|
projectService: projectService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExportRequest struct {
|
type ExportRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Items []struct {
|
ProjectName string `json:"project_name"`
|
||||||
|
ProjectUUID string `json:"project_uuid"`
|
||||||
|
Article string `json:"article"`
|
||||||
|
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"`
|
||||||
UnitPrice float64 `json:"unit_price"`
|
UnitPrice float64 `json:"unit_price"`
|
||||||
@@ -47,15 +54,47 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
|||||||
|
|
||||||
data := h.buildExportData(&req)
|
data := h.buildExportData(&req)
|
||||||
|
|
||||||
csvData, err := h.exportService.ToCSV(data)
|
// Validate before streaming (can return JSON error)
|
||||||
if err != nil {
|
if len(data.Items) == 0 {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := fmt.Sprintf("%s %s SPEC.csv", time.Now().Format("2006-01-02"), req.Name)
|
// Get project name if available
|
||||||
|
projectName := req.ProjectName
|
||||||
|
if projectName == "" && req.ProjectUUID != "" {
|
||||||
|
// Try to load project name from database
|
||||||
|
username := middleware.GetUsername(c)
|
||||||
|
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
|
||||||
|
projectName = derefString(project.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if projectName == "" {
|
||||||
|
projectName = 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"), projectName, req.Name, articleSegment)
|
||||||
|
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))
|
||||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
|
||||||
|
// Stream CSV (cannot return JSON after this point)
|
||||||
|
if err := h.exportService.ToCSV(c.Writer, data); err != nil {
|
||||||
|
c.Error(err) // Log only
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func derefString(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *value
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
|
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
|
||||||
@@ -90,6 +129,7 @@ func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData
|
|||||||
|
|
||||||
return &services.ExportData{
|
return &services.ExportData{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
|
Article: req.Article,
|
||||||
Items: items,
|
Items: items,
|
||||||
Total: total,
|
Total: total,
|
||||||
Notes: req.Notes,
|
Notes: req.Notes,
|
||||||
@@ -97,10 +137,29 @@ func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
username := middleware.GetUsername(c)
|
username := middleware.GetUsername(c)
|
||||||
uuid := c.Param("uuid")
|
uuid := c.Param("uuid")
|
||||||
|
|
||||||
|
// Get config before streaming (can return JSON error)
|
||||||
config, err := h.configService.GetByUUID(uuid, username)
|
config, err := h.configService.GetByUUID(uuid, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
@@ -109,13 +168,33 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
|||||||
|
|
||||||
data := h.exportService.ConfigToExportData(config, h.componentService)
|
data := h.exportService.ConfigToExportData(config, h.componentService)
|
||||||
|
|
||||||
csvData, err := h.exportService.ToCSV(data)
|
// Validate before streaming (can return JSON error)
|
||||||
if err != nil {
|
if len(data.Items) == 0 {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := fmt.Sprintf("%s %s SPEC.csv", config.CreatedAt.Format("2006-01-02"), config.Name)
|
// Get project name if configuration belongs to a project
|
||||||
|
projectName := 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 {
|
||||||
|
projectName = derefString(project.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers before streaming
|
||||||
|
// Use price update time if available, otherwise creation time
|
||||||
|
exportDate := config.CreatedAt
|
||||||
|
if config.PriceUpdatedAt != nil {
|
||||||
|
exportDate = *config.PriceUpdatedAt
|
||||||
|
}
|
||||||
|
filename := fmt.Sprintf("%s (%s) %s BOM.csv", exportDate.Format("2006-01-02"), projectName, config.Name)
|
||||||
|
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))
|
||||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
|
||||||
|
// Stream CSV (cannot return JSON after this point)
|
||||||
|
if err := h.exportService.ToCSV(c.Writer, data); err != nil {
|
||||||
|
c.Error(err) // Log only
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
314
internal/handlers/export_test.go
Normal file
314
internal/handlers/export_test.go
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mock services for testing
|
||||||
|
type mockConfigService struct {
|
||||||
|
config *models.Configuration
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockConfigService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) {
|
||||||
|
return m.config, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestExportCSV_Success(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Create a basic mock component service that doesn't panic
|
||||||
|
mockComponentService := &services.ComponentService{}
|
||||||
|
|
||||||
|
// Create handler with mocks
|
||||||
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||||
|
handler := NewExportHandler(
|
||||||
|
exportSvc,
|
||||||
|
&mockConfigService{},
|
||||||
|
mockComponentService,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create JSON request body
|
||||||
|
jsonBody := `{
|
||||||
|
"name": "Test Export",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"lot_name": "LOT-001",
|
||||||
|
"quantity": 2,
|
||||||
|
"unit_price": 100.50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notes": "Test notes"
|
||||||
|
}`
|
||||||
|
|
||||||
|
// Create HTTP request
|
||||||
|
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(jsonBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Create response recorder
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Create Gin context
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
// Call handler
|
||||||
|
handler.ExportCSV(c)
|
||||||
|
|
||||||
|
// Check status code
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Content-Type header
|
||||||
|
contentType := w.Header().Get("Content-Type")
|
||||||
|
if contentType != "text/csv; charset=utf-8" {
|
||||||
|
t.Errorf("Expected Content-Type 'text/csv; charset=utf-8', got %q", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for BOM
|
||||||
|
responseBody := w.Body.Bytes()
|
||||||
|
if len(responseBody) < 3 {
|
||||||
|
t.Fatalf("Response too short to contain BOM")
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||||
|
actualBOM := responseBody[:3]
|
||||||
|
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
||||||
|
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check semicolon delimiter in CSV
|
||||||
|
reader := csv.NewReader(bytes.NewReader(responseBody[3:]))
|
||||||
|
reader.Comma = ';'
|
||||||
|
|
||||||
|
header, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to parse CSV header: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(header) != 6 {
|
||||||
|
t.Errorf("Expected 6 columns, got %d", len(header))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportCSV_InvalidRequest(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||||
|
handler := NewExportHandler(
|
||||||
|
exportSvc,
|
||||||
|
&mockConfigService{},
|
||||||
|
&services.ComponentService{},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create invalid request (missing required field)
|
||||||
|
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(`{"name": "Test"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler.ExportCSV(c)
|
||||||
|
|
||||||
|
// Should return 400 Bad Request
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("Expected status 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should return JSON error
|
||||||
|
var errResp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &errResp)
|
||||||
|
if _, hasError := errResp["error"]; !hasError {
|
||||||
|
t.Errorf("Expected error in JSON response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportCSV_EmptyItems(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||||
|
handler := NewExportHandler(
|
||||||
|
exportSvc,
|
||||||
|
&mockConfigService{},
|
||||||
|
&services.ComponentService{},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create request with empty items array - should fail binding validation
|
||||||
|
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(`{"name":"Empty Export","items":[],"notes":""}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler.ExportCSV(c)
|
||||||
|
|
||||||
|
// Should return 400 Bad Request (validation error from gin binding)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Logf("Status code: %d (expected 400 for empty items)", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportConfigCSV_Success(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Mock configuration
|
||||||
|
mockConfig := &models.Configuration{
|
||||||
|
UUID: "test-uuid",
|
||||||
|
Name: "Test Config",
|
||||||
|
OwnerUsername: "testuser",
|
||||||
|
Items: models.ConfigItems{
|
||||||
|
{
|
||||||
|
LotName: "LOT-001",
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: 100.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||||
|
handler := NewExportHandler(
|
||||||
|
exportSvc,
|
||||||
|
&mockConfigService{config: mockConfig},
|
||||||
|
&services.ComponentService{},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create HTTP request
|
||||||
|
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = req
|
||||||
|
c.Params = gin.Params{
|
||||||
|
{Key: "uuid", Value: "test-uuid"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock middleware.GetUsername
|
||||||
|
c.Set("username", "testuser")
|
||||||
|
|
||||||
|
handler.ExportConfigCSV(c)
|
||||||
|
|
||||||
|
// Check status code
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Content-Type header
|
||||||
|
contentType := w.Header().Get("Content-Type")
|
||||||
|
if contentType != "text/csv; charset=utf-8" {
|
||||||
|
t.Errorf("Expected Content-Type 'text/csv; charset=utf-8', got %q", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for BOM
|
||||||
|
responseBody := w.Body.Bytes()
|
||||||
|
if len(responseBody) < 3 {
|
||||||
|
t.Fatalf("Response too short to contain BOM")
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||||
|
actualBOM := responseBody[:3]
|
||||||
|
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
||||||
|
t.Errorf("UTF-8 BOM mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportConfigCSV_NotFound(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||||
|
handler := NewExportHandler(
|
||||||
|
exportSvc,
|
||||||
|
&mockConfigService{err: errors.New("config not found")},
|
||||||
|
&services.ComponentService{},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", "/api/configs/nonexistent-uuid/export", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = req
|
||||||
|
c.Params = gin.Params{
|
||||||
|
{Key: "uuid", Value: "nonexistent-uuid"},
|
||||||
|
}
|
||||||
|
c.Set("username", "testuser")
|
||||||
|
|
||||||
|
handler.ExportConfigCSV(c)
|
||||||
|
|
||||||
|
// Should return 404 Not Found
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("Expected status 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should return JSON error
|
||||||
|
var errResp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &errResp)
|
||||||
|
if _, hasError := errResp["error"]; !hasError {
|
||||||
|
t.Errorf("Expected error in JSON response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportConfigCSV_EmptyItems(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Mock configuration with empty items
|
||||||
|
mockConfig := &models.Configuration{
|
||||||
|
UUID: "test-uuid",
|
||||||
|
Name: "Empty Config",
|
||||||
|
OwnerUsername: "testuser",
|
||||||
|
Items: models.ConfigItems{},
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||||
|
handler := NewExportHandler(
|
||||||
|
exportSvc,
|
||||||
|
&mockConfigService{config: mockConfig},
|
||||||
|
&services.ComponentService{},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = req
|
||||||
|
c.Params = gin.Params{
|
||||||
|
{Key: "uuid", Value: "test-uuid"},
|
||||||
|
}
|
||||||
|
c.Set("username", "testuser")
|
||||||
|
|
||||||
|
handler.ExportConfigCSV(c)
|
||||||
|
|
||||||
|
// Should return 400 Bad Request
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("Expected status 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should return JSON error
|
||||||
|
var errResp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &errResp)
|
||||||
|
if _, hasError := errResp["error"]; !hasError {
|
||||||
|
t.Errorf("Expected error in JSON response")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -28,14 +28,13 @@ type ComponentSyncResult struct {
|
|||||||
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
|
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
// Query to join lot with qt_lot_metadata
|
// Query to join lot with qt_lot_metadata (metadata only, no pricing)
|
||||||
// Use LEFT JOIN to include lots without metadata
|
// Use LEFT JOIN to include lots without metadata
|
||||||
type componentRow struct {
|
type componentRow struct {
|
||||||
LotName string
|
LotName string
|
||||||
LotDescription string
|
LotDescription string
|
||||||
Category *string
|
Category *string
|
||||||
Model *string
|
Model *string
|
||||||
CurrentPrice *float64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var rows []componentRow
|
var rows []componentRow
|
||||||
@@ -44,8 +43,7 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
|
|||||||
l.lot_name,
|
l.lot_name,
|
||||||
l.lot_description,
|
l.lot_description,
|
||||||
COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category,
|
COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category,
|
||||||
m.model,
|
m.model
|
||||||
m.current_price
|
|
||||||
FROM lot l
|
FROM lot l
|
||||||
LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name
|
LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name
|
||||||
LEFT JOIN qt_categories c ON m.category_id = c.id
|
LEFT JOIN qt_categories c ON m.category_id = c.id
|
||||||
@@ -100,8 +98,6 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
|
|||||||
LotDescription: row.LotDescription,
|
LotDescription: row.LotDescription,
|
||||||
Category: category,
|
Category: category,
|
||||||
Model: model,
|
Model: model,
|
||||||
CurrentPrice: row.CurrentPrice,
|
|
||||||
SyncedAt: syncTime,
|
|
||||||
}
|
}
|
||||||
components = append(components, comp)
|
components = append(components, comp)
|
||||||
|
|
||||||
@@ -221,11 +217,6 @@ func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]L
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply price filter
|
|
||||||
if filter.HasPrice {
|
|
||||||
db = db.Where("current_price IS NOT NULL")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get total count
|
// Get total count
|
||||||
var total int64
|
var total int64
|
||||||
if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil {
|
if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil {
|
||||||
@@ -251,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
|
||||||
@@ -311,99 +327,3 @@ func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
|
|||||||
}
|
}
|
||||||
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
|
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateComponentPricesFromPricelist updates current_price in local_components from pricelist items
|
|
||||||
// This allows offline price updates using synced pricelists without MariaDB connection
|
|
||||||
func (l *LocalDB) UpdateComponentPricesFromPricelist(pricelistID uint) (int, error) {
|
|
||||||
// Get all items from the specified pricelist
|
|
||||||
var items []LocalPricelistItem
|
|
||||||
if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil {
|
|
||||||
return 0, fmt.Errorf("fetching pricelist items: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(items) == 0 {
|
|
||||||
slog.Warn("no items found in pricelist", "pricelist_id", pricelistID)
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update current_price for each component
|
|
||||||
updated := 0
|
|
||||||
err := l.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
for _, item := range items {
|
|
||||||
result := tx.Model(&LocalComponent{}).
|
|
||||||
Where("lot_name = ?", item.LotName).
|
|
||||||
Update("current_price", item.Price)
|
|
||||||
|
|
||||||
if result.Error != nil {
|
|
||||||
return fmt.Errorf("updating price for %s: %w", item.LotName, result.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.RowsAffected > 0 {
|
|
||||||
updated++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("updated component prices from pricelist",
|
|
||||||
"pricelist_id", pricelistID,
|
|
||||||
"total_items", len(items),
|
|
||||||
"updated_components", updated)
|
|
||||||
|
|
||||||
return updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureComponentPricesFromPricelists loads prices from the latest pricelist into local_components
|
|
||||||
// if no components exist or all current prices are NULL
|
|
||||||
func (l *LocalDB) EnsureComponentPricesFromPricelists() error {
|
|
||||||
// Check if we have any components with prices
|
|
||||||
var count int64
|
|
||||||
if err := l.db.Model(&LocalComponent{}).Where("current_price IS NOT NULL").Count(&count).Error; err != nil {
|
|
||||||
return fmt.Errorf("checking component prices: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have components with prices, don't load from pricelists
|
|
||||||
if count > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we have any components at all
|
|
||||||
var totalComponents int64
|
|
||||||
if err := l.db.Model(&LocalComponent{}).Count(&totalComponents).Error; err != nil {
|
|
||||||
return fmt.Errorf("counting components: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have no components, we need to load them from pricelists
|
|
||||||
if totalComponents == 0 {
|
|
||||||
slog.Info("no components found in local database, loading from latest pricelist")
|
|
||||||
// This would typically be called from the sync service or setup process
|
|
||||||
// For now, we'll just return nil to indicate no action needed
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have components but no prices, load from latest estimate pricelist.
|
|
||||||
var latestPricelist LocalPricelist
|
|
||||||
if err := l.db.Where("source = ?", "estimate").Order("created_at DESC").First(&latestPricelist).Error; err != nil {
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
slog.Warn("no pricelists found in local database")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("finding latest pricelist: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update prices from the latest pricelist
|
|
||||||
updated, err := l.UpdateComponentPricesFromPricelist(latestPricelist.ID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("updating component prices from pricelist: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("loaded component prices from latest pricelist",
|
|
||||||
"pricelist_id", latestPricelist.ID,
|
|
||||||
"updated_components", updated)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -100,6 +106,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 +127,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 +179,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 +194,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,
|
||||||
@@ -213,17 +225,14 @@ func ComponentToLocal(meta *models.LotMetadata) *LocalComponent {
|
|||||||
LotDescription: lotDesc,
|
LotDescription: lotDesc,
|
||||||
Category: category,
|
Category: category,
|
||||||
Model: meta.Model,
|
Model: meta.Model,
|
||||||
CurrentPrice: meta.CurrentPrice,
|
|
||||||
SyncedAt: time.Now(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LocalToComponent converts LocalComponent to models.LotMetadata
|
// LocalToComponent converts LocalComponent to models.LotMetadata
|
||||||
func LocalToComponent(local *LocalComponent) *models.LotMetadata {
|
func LocalToComponent(local *LocalComponent) *models.LotMetadata {
|
||||||
return &models.LotMetadata{
|
return &models.LotMetadata{
|
||||||
LotName: local.LotName,
|
LotName: local.LotName,
|
||||||
Model: local.Model,
|
Model: local.Model,
|
||||||
CurrentPrice: local.CurrentPrice,
|
|
||||||
Lot: &models.Lot{
|
Lot: &models.Lot{
|
||||||
LotName: local.LotName,
|
LotName: local.LotName,
|
||||||
LotDescription: local.LotDescription,
|
LotDescription: local.LotDescription,
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -58,6 +58,51 @@ var localMigrations = []localMigration{
|
|||||||
name: "Backfill source for local pricelists and create source indexes",
|
name: "Backfill source for local pricelists and create source indexes",
|
||||||
run: backfillLocalPricelistSource,
|
run: backfillLocalPricelistSource,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_09_drop_component_unused_fields",
|
||||||
|
name: "Remove current_price and synced_at from local_components (unused fields)",
|
||||||
|
run: dropComponentUnusedFields,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_09_add_warehouse_competitor_pricelists",
|
||||||
|
name: "Add warehouse_pricelist_id and competitor_pricelist_id to local_configurations",
|
||||||
|
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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func runLocalMigrations(db *gorm.DB) error {
|
func runLocalMigrations(db *gorm.DB) error {
|
||||||
@@ -194,7 +239,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,
|
||||||
@@ -208,6 +254,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 {
|
||||||
@@ -249,6 +428,7 @@ func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
|
|||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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"`
|
||||||
@@ -316,3 +496,222 @@ func backfillLocalPricelistSource(tx *gorm.DB) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dropComponentUnusedFields(tx *gorm.DB) error {
|
||||||
|
// Check if columns exist
|
||||||
|
type columnInfo struct {
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var columns []columnInfo
|
||||||
|
if err := tx.Raw(`
|
||||||
|
SELECT name FROM pragma_table_info('local_components')
|
||||||
|
WHERE name IN ('current_price', 'synced_at')
|
||||||
|
`).Scan(&columns).Error; err != nil {
|
||||||
|
return fmt.Errorf("check columns existence: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(columns) == 0 {
|
||||||
|
slog.Info("unused fields already removed from local_components")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQLite: recreate table without current_price and synced_at
|
||||||
|
if err := tx.Exec(`
|
||||||
|
CREATE TABLE local_components_new (
|
||||||
|
lot_name TEXT PRIMARY KEY,
|
||||||
|
lot_description TEXT,
|
||||||
|
category TEXT,
|
||||||
|
model TEXT
|
||||||
|
)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("create new local_components table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
INSERT INTO local_components_new (lot_name, lot_description, category, model)
|
||||||
|
SELECT lot_name, lot_description, category, model
|
||||||
|
FROM local_components
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("copy data to new table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`DROP TABLE local_components`).Error; err != nil {
|
||||||
|
return fmt.Errorf("drop old table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`ALTER TABLE local_components_new RENAME TO local_components`).Error; err != nil {
|
||||||
|
return fmt.Errorf("rename new table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("dropped current_price and synced_at columns from local_components")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addWarehouseCompetitorPriceLists(tx *gorm.DB) error {
|
||||||
|
// Check if columns exist
|
||||||
|
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 ('warehouse_pricelist_id', 'competitor_pricelist_id')
|
||||||
|
`).Scan(&columns).Error; err != nil {
|
||||||
|
return fmt.Errorf("check columns existence: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(columns) == 2 {
|
||||||
|
slog.Info("warehouse and competitor pricelist columns already exist")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add columns if they don't exist
|
||||||
|
if err := tx.Exec(`
|
||||||
|
ALTER TABLE local_configurations
|
||||||
|
ADD COLUMN warehouse_pricelist_id INTEGER
|
||||||
|
`).Error; err != nil {
|
||||||
|
// Column might already exist, ignore
|
||||||
|
if !strings.Contains(err.Error(), "duplicate column") {
|
||||||
|
return fmt.Errorf("add warehouse_pricelist_id column: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
ALTER TABLE local_configurations
|
||||||
|
ADD COLUMN competitor_pricelist_id INTEGER
|
||||||
|
`).Error; err != nil {
|
||||||
|
// Column might already exist, ignore
|
||||||
|
if !strings.Contains(err.Error(), "duplicate column") {
|
||||||
|
return fmt.Errorf("add competitor_pricelist_id column: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
if err := tx.Exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_local_configurations_warehouse_pricelist
|
||||||
|
ON local_configurations(warehouse_pricelist_id)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("create warehouse pricelist index: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_local_configurations_competitor_pricelist
|
||||||
|
ON local_configurations(competitor_pricelist_id)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("create competitor pricelist index: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("added warehouse and competitor pricelist fields to local_configurations")
|
||||||
|
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,8 +96,13 @@ 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"`
|
||||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
|
||||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
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"`
|
||||||
|
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||||
|
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||||
|
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 `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -118,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"`
|
||||||
@@ -170,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"`
|
||||||
@@ -179,14 +187,13 @@ func (LocalPricelistItem) TableName() string {
|
|||||||
return "local_pricelist_items"
|
return "local_pricelist_items"
|
||||||
}
|
}
|
||||||
|
|
||||||
// LocalComponent stores cached components for offline search
|
// LocalComponent stores cached components for offline search (metadata only)
|
||||||
|
// All pricing is now sourced from local_pricelist_items based on configuration pricelist selection
|
||||||
type LocalComponent struct {
|
type LocalComponent struct {
|
||||||
LotName string `gorm:"primaryKey" json:"lot_name"`
|
LotName string `gorm:"primaryKey" json:"lot_name"`
|
||||||
LotDescription string `json:"lot_description"`
|
LotDescription string `json:"lot_description"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
CurrentPrice *float64 `json:"current_price"`
|
|
||||||
SyncedAt time.Time `json:"synced_at"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (LocalComponent) TableName() string {
|
func (LocalComponent) TableName() string {
|
||||||
|
|||||||
@@ -22,6 +22,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 +55,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 +84,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,
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -83,10 +83,6 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit
|
|||||||
search := "%" + filter.Search + "%"
|
search := "%" + filter.Search + "%"
|
||||||
query = query.Where("lot_name LIKE ? OR lot_description LIKE ? OR model LIKE ?", search, search, search)
|
query = query.Where("lot_name LIKE ? OR lot_description LIKE ? OR model LIKE ?", search, search, search)
|
||||||
}
|
}
|
||||||
if filter.HasPrice {
|
|
||||||
query = query.Where("current_price IS NOT NULL AND current_price > 0")
|
|
||||||
}
|
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
query.Count(&total)
|
query.Count(&total)
|
||||||
|
|
||||||
@@ -96,8 +92,6 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit
|
|||||||
sortDir = "DESC"
|
sortDir = "DESC"
|
||||||
}
|
}
|
||||||
switch filter.SortField {
|
switch filter.SortField {
|
||||||
case "current_price":
|
|
||||||
query = query.Order("current_price " + sortDir)
|
|
||||||
case "lot_name":
|
case "lot_name":
|
||||||
query = query.Order("lot_name " + sortDir)
|
query = query.Order("lot_name " + sortDir)
|
||||||
default:
|
default:
|
||||||
@@ -112,9 +106,8 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit
|
|||||||
result := make([]models.LotMetadata, len(components))
|
result := make([]models.LotMetadata, len(components))
|
||||||
for i, comp := range components {
|
for i, comp := range components {
|
||||||
result[i] = models.LotMetadata{
|
result[i] = models.LotMetadata{
|
||||||
LotName: comp.LotName,
|
LotName: comp.LotName,
|
||||||
Model: comp.Model,
|
Model: comp.Model,
|
||||||
CurrentPrice: comp.CurrentPrice,
|
|
||||||
Lot: &models.Lot{
|
Lot: &models.Lot{
|
||||||
LotName: comp.LotName,
|
LotName: comp.LotName,
|
||||||
LotDescription: comp.LotDescription,
|
LotDescription: comp.LotDescription,
|
||||||
@@ -138,9 +131,8 @@ func (r *UnifiedRepo) GetComponent(lotName string) (*models.LotMetadata, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &models.LotMetadata{
|
return &models.LotMetadata{
|
||||||
LotName: comp.LotName,
|
LotName: comp.LotName,
|
||||||
Model: comp.Model,
|
Model: comp.Model,
|
||||||
CurrentPrice: comp.CurrentPrice,
|
|
||||||
Lot: &models.Lot{
|
Lot: &models.Lot{
|
||||||
LotName: comp.LotName,
|
LotName: comp.LotName,
|
||||||
LotDescription: comp.LotDescription,
|
LotDescription: comp.LotDescription,
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ type ComponentView struct {
|
|||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
CategoryName string `json:"category_name"`
|
CategoryName string `json:"category_name"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
CurrentPrice *float64 `json:"current_price"`
|
|
||||||
PriceFreshness models.PriceFreshness `json:"price_freshness"`
|
PriceFreshness models.PriceFreshness `json:"price_freshness"`
|
||||||
PopularityScore float64 `json:"popularity_score"`
|
PopularityScore float64 `json:"popularity_score"`
|
||||||
Specs models.Specs `json:"specs,omitempty"`
|
Specs models.Specs `json:"specs,omitempty"`
|
||||||
@@ -92,7 +91,6 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
|
|||||||
view := ComponentView{
|
view := ComponentView{
|
||||||
LotName: c.LotName,
|
LotName: c.LotName,
|
||||||
Model: c.Model,
|
Model: c.Model,
|
||||||
CurrentPrice: c.CurrentPrice,
|
|
||||||
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
||||||
PopularityScore: c.PopularityScore,
|
PopularityScore: c.PopularityScore,
|
||||||
Specs: c.Specs,
|
Specs: c.Specs,
|
||||||
@@ -134,7 +132,6 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
|
|||||||
view := &ComponentView{
|
view := &ComponentView{
|
||||||
LotName: c.LotName,
|
LotName: c.LotName,
|
||||||
Model: c.Model,
|
Model: c.Model,
|
||||||
CurrentPrice: c.CurrentPrice,
|
|
||||||
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
||||||
PopularityScore: c.PopularityScore,
|
PopularityScore: c.PopularityScore,
|
||||||
Specs: c.Specs,
|
Specs: c.Specs,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
@@ -25,6 +27,7 @@ func NewExportService(cfg config.ExportConfig, categoryRepo *repository.Category
|
|||||||
|
|
||||||
type ExportData struct {
|
type ExportData struct {
|
||||||
Name string
|
Name string
|
||||||
|
Article string
|
||||||
Items []ExportItem
|
Items []ExportItem
|
||||||
Total float64
|
Total float64
|
||||||
Notes string
|
Notes string
|
||||||
@@ -40,14 +43,21 @@ type ExportItem struct {
|
|||||||
TotalPrice float64
|
TotalPrice float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
|
func (s *ExportService) ToCSV(w io.Writer, data *ExportData) error {
|
||||||
var buf bytes.Buffer
|
// Write UTF-8 BOM for Excel compatibility
|
||||||
w := csv.NewWriter(&buf)
|
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||||
|
return fmt.Errorf("failed to write BOM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csvWriter := csv.NewWriter(w)
|
||||||
|
// Use semicolon as delimiter for Russian Excel locale
|
||||||
|
csvWriter.Comma = ';'
|
||||||
|
defer csvWriter.Flush()
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
headers := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
|
headers := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
|
||||||
if err := w.Write(headers); err != nil {
|
if err := csvWriter.Write(headers); err != nil {
|
||||||
return nil, err
|
return fmt.Errorf("failed to write header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get category hierarchy for sorting
|
// Get category hierarchy for sorting
|
||||||
@@ -90,21 +100,35 @@ func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
|
|||||||
item.Description,
|
item.Description,
|
||||||
item.Category,
|
item.Category,
|
||||||
fmt.Sprintf("%d", item.Quantity),
|
fmt.Sprintf("%d", item.Quantity),
|
||||||
fmt.Sprintf("%.2f", item.UnitPrice),
|
strings.ReplaceAll(fmt.Sprintf("%.2f", item.UnitPrice), ".", ","),
|
||||||
fmt.Sprintf("%.2f", item.TotalPrice),
|
strings.ReplaceAll(fmt.Sprintf("%.2f", item.TotalPrice), ".", ","),
|
||||||
}
|
}
|
||||||
if err := w.Write(row); err != nil {
|
if err := csvWriter.Write(row); err != nil {
|
||||||
return nil, err
|
return fmt.Errorf("failed to write row: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Total row
|
// Total row
|
||||||
if err := w.Write([]string{"", "", "", "", "ИТОГО:", fmt.Sprintf("%.2f", data.Total)}); err != nil {
|
totalStr := strings.ReplaceAll(fmt.Sprintf("%.2f", data.Total), ".", ",")
|
||||||
return nil, err
|
if err := csvWriter.Write([]string{data.Article, "", "", "", "ИТОГО:", totalStr}); err != nil {
|
||||||
|
return fmt.Errorf("failed to write total row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Flush()
|
csvWriter.Flush()
|
||||||
return buf.Bytes(), w.Error()
|
if err := csvWriter.Error(); err != nil {
|
||||||
|
return fmt.Errorf("csv writer error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToCSVBytes is a backward-compatible wrapper that returns CSV data as bytes
|
||||||
|
func (s *ExportService) ToCSVBytes(data *ExportData) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := s.ToCSV(&buf, data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExportService) ConfigToExportData(config *models.Configuration, componentService *ComponentService) *ExportData {
|
func (s *ExportService) ConfigToExportData(config *models.Configuration, componentService *ComponentService) *ExportData {
|
||||||
@@ -139,6 +163,7 @@ func (s *ExportService) ConfigToExportData(config *models.Configuration, compone
|
|||||||
|
|
||||||
return &ExportData{
|
return &ExportData{
|
||||||
Name: config.Name,
|
Name: config.Name,
|
||||||
|
Article: "",
|
||||||
Items: items,
|
Items: items,
|
||||||
Total: total,
|
Total: total,
|
||||||
Notes: config.Notes,
|
Notes: config.Notes,
|
||||||
|
|||||||
343
internal/services/export_test.go
Normal file
343
internal/services/export_test.go
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/csv"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func TestToCSV_UTF8BOM(t *testing.T) {
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
|
data := &ExportData{
|
||||||
|
Name: "Test",
|
||||||
|
Items: []ExportItem{
|
||||||
|
{
|
||||||
|
LotName: "LOT-001",
|
||||||
|
Description: "Test Item",
|
||||||
|
Category: "CAT",
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: 100.0,
|
||||||
|
TotalPrice: 100.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Total: 100.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()
|
||||||
|
if len(csvBytes) < 3 {
|
||||||
|
t.Fatalf("CSV too short to contain BOM")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check UTF-8 BOM: 0xEF 0xBB 0xBF
|
||||||
|
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||||
|
actualBOM := csvBytes[:3]
|
||||||
|
|
||||||
|
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
||||||
|
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToCSV_SemicolonDelimiter(t *testing.T) {
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
|
data := &ExportData{
|
||||||
|
Name: "Test",
|
||||||
|
Items: []ExportItem{
|
||||||
|
{
|
||||||
|
LotName: "LOT-001",
|
||||||
|
Description: "Test Item",
|
||||||
|
Category: "CAT",
|
||||||
|
Quantity: 2,
|
||||||
|
UnitPrice: 100.50,
|
||||||
|
TotalPrice: 201.00,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Total: 201.00,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := svc.ToCSV(&buf, data); err != nil {
|
||||||
|
t.Fatalf("ToCSV failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip BOM and read CSV with semicolon delimiter
|
||||||
|
csvBytes := buf.Bytes()
|
||||||
|
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||||
|
reader.Comma = ';'
|
||||||
|
|
||||||
|
// Read header
|
||||||
|
header, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read header: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(header) != 6 {
|
||||||
|
t.Errorf("Expected 6 columns, got %d", len(header))
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedHeader := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
|
||||||
|
for i, col := range expectedHeader {
|
||||||
|
if i < len(header) && header[i] != col {
|
||||||
|
t.Errorf("Column %d: expected %q, got %q", i, col, header[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read item row
|
||||||
|
itemRow, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read item row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if itemRow[0] != "LOT-001" {
|
||||||
|
t.Errorf("Lot name mismatch: expected LOT-001, got %s", itemRow[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
if itemRow[3] != "2" {
|
||||||
|
t.Errorf("Quantity mismatch: expected 2, got %s", itemRow[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
if itemRow[4] != "100,50" {
|
||||||
|
t.Errorf("Unit price mismatch: expected 100,50, got %s", itemRow[4])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToCSV_TotalRow(t *testing.T) {
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
|
data := &ExportData{
|
||||||
|
Name: "Test",
|
||||||
|
Items: []ExportItem{
|
||||||
|
{
|
||||||
|
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
|
||||||
|
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
|
||||||
|
reader.Read()
|
||||||
|
|
||||||
|
// Without category repo, items maintain original order
|
||||||
|
row1, _ := reader.Read()
|
||||||
|
if row1[0] != "LOT-001" {
|
||||||
|
t.Errorf("Expected LOT-001 first, got %s", row1[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
row2, _ := reader.Read()
|
||||||
|
if row2[0] != "LOT-002" {
|
||||||
|
t.Errorf("Expected LOT-002 second, got %s", row2[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
row3, _ := reader.Read()
|
||||||
|
if row3[0] != "LOT-003" {
|
||||||
|
t.Errorf("Expected LOT-003 third, got %s", row3[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToCSV_EmptyData(t *testing.T) {
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
|
data := &ExportData{
|
||||||
|
Name: "Test",
|
||||||
|
Items: []ExportItem{},
|
||||||
|
Total: 0.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 = ';'
|
||||||
|
|
||||||
|
// Should have header and total row
|
||||||
|
header, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read header: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(header) != 6 {
|
||||||
|
t.Errorf("Expected 6 columns, got %d", len(header))
|
||||||
|
}
|
||||||
|
|
||||||
|
totalRow, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read total row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalRow[4] != "ИТОГО:" {
|
||||||
|
t.Errorf("Expected ИТОГО: in total row, got %s", totalRow[4])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToCSVBytes_BackwardCompat(t *testing.T) {
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
|
data := &ExportData{
|
||||||
|
Name: "Test",
|
||||||
|
Items: []ExportItem{
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ToCSVBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(csvBytes) < 3 {
|
||||||
|
t.Fatalf("CSV bytes too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify BOM is present
|
||||||
|
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||||
|
actualBOM := csvBytes[:3]
|
||||||
|
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
||||||
|
t.Errorf("UTF-8 BOM mismatch in ToCSVBytes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToCSV_WriterError(t *testing.T) {
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
|
data := &ExportData{
|
||||||
|
Name: "Test",
|
||||||
|
Items: []ExportItem{
|
||||||
|
{
|
||||||
|
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{}
|
||||||
|
|
||||||
|
if err := svc.ToCSV(failingWriter, data); err == nil {
|
||||||
|
t.Errorf("Expected error from failing writer, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// failingWriter always returns an error
|
||||||
|
type failingWriter struct{}
|
||||||
|
|
||||||
|
func (fw *failingWriter) Write(p []byte) (int, error) {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
@@ -347,7 +394,7 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
|||||||
}
|
}
|
||||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
||||||
|
|
||||||
// Update prices for all items
|
// Update prices for all items from pricelist
|
||||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||||
for i, item := range localCfg.Items {
|
for i, item := range localCfg.Items {
|
||||||
if latestErr == nil && latestPricelist != nil {
|
if latestErr == nil && latestPricelist != nil {
|
||||||
@@ -362,20 +409,8 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to current component price from local cache
|
// Keep original item if price not found in pricelist
|
||||||
component, err := s.localDB.GetLocalComponent(item.LotName)
|
updatedItems[i] = item
|
||||||
if err != nil || component.CurrentPrice == nil {
|
|
||||||
// Keep original item if component not found or no price available
|
|
||||||
updatedItems[i] = item
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update item with current price from local cache
|
|
||||||
updatedItems[i] = localdb.LocalConfigItem{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: *component.CurrentPrice,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update configuration
|
// Update configuration
|
||||||
@@ -436,6 +471,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)
|
||||||
@@ -456,6 +503,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()
|
||||||
@@ -672,7 +722,7 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
|||||||
}
|
}
|
||||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
||||||
|
|
||||||
// Update prices for all items
|
// Update prices for all items from pricelist
|
||||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||||
for i, item := range localCfg.Items {
|
for i, item := range localCfg.Items {
|
||||||
if latestErr == nil && latestPricelist != nil {
|
if latestErr == nil && latestPricelist != nil {
|
||||||
@@ -687,20 +737,8 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to current component price from local cache
|
// Keep original item if price not found in pricelist
|
||||||
component, err := s.localDB.GetLocalComponent(item.LotName)
|
updatedItems[i] = item
|
||||||
if err != nil || component.CurrentPrice == nil {
|
|
||||||
// Keep original item if component not found or no price available
|
|
||||||
updatedItems[i] = item
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update item with current price from local cache
|
|
||||||
updatedItems[i] = localdb.LocalConfigItem{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: *component.CurrentPrice,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update configuration
|
// Update configuration
|
||||||
|
|||||||
@@ -191,7 +191,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 +228,10 @@ func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ 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")
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProjectService struct {
|
type ProjectService struct {
|
||||||
@@ -29,12 +30,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 +50,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 +94,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 +133,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)
|
||||||
}
|
}
|
||||||
@@ -116,9 +179,6 @@ func (s *ProjectService) setProjectActive(projectUUID, ownerUsername string, isA
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ type QuoteRequest struct {
|
|||||||
LotName string `json:"lot_name"`
|
LotName string `json:"lot_name"`
|
||||||
Quantity int `json:"quantity"`
|
Quantity int `json:"quantity"`
|
||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
|
PricelistID *uint `json:"pricelist_id,omitempty"` // Optional: use specific pricelist for pricing
|
||||||
}
|
}
|
||||||
|
|
||||||
type PriceLevelsRequest struct {
|
type PriceLevelsRequest struct {
|
||||||
@@ -123,6 +124,16 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
|
|||||||
Warnings: make([]string, 0),
|
Warnings: make([]string, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine which pricelist to use for pricing
|
||||||
|
pricelistID := req.PricelistID
|
||||||
|
if pricelistID == nil || *pricelistID == 0 {
|
||||||
|
// By default, use latest estimate pricelist
|
||||||
|
latestPricelist, err := s.localDB.GetLatestLocalPricelistBySource("estimate")
|
||||||
|
if err == nil && latestPricelist != nil {
|
||||||
|
pricelistID = &latestPricelist.ServerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var total float64
|
var total float64
|
||||||
for _, reqItem := range req.Items {
|
for _, reqItem := range req.Items {
|
||||||
localComp, err := s.localDB.GetLocalComponent(reqItem.LotName)
|
localComp, err := s.localDB.GetLocalComponent(reqItem.LotName)
|
||||||
@@ -142,13 +153,19 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
|
|||||||
TotalPrice: 0,
|
TotalPrice: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
if localComp.CurrentPrice != nil && *localComp.CurrentPrice > 0 {
|
// Get price from pricelist_items
|
||||||
item.UnitPrice = *localComp.CurrentPrice
|
if pricelistID != nil {
|
||||||
item.TotalPrice = *localComp.CurrentPrice * float64(reqItem.Quantity)
|
price, found := s.lookupPriceByPricelistID(*pricelistID, reqItem.LotName)
|
||||||
item.HasPrice = true
|
if found && price > 0 {
|
||||||
total += item.TotalPrice
|
item.UnitPrice = price
|
||||||
|
item.TotalPrice = price * float64(reqItem.Quantity)
|
||||||
|
item.HasPrice = true
|
||||||
|
total += item.TotalPrice
|
||||||
|
} else {
|
||||||
|
result.Warnings = append(result.Warnings, "No price available for: "+reqItem.LotName)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
result.Warnings = append(result.Warnings, "No price available for: "+reqItem.LotName)
|
result.Warnings = append(result.Warnings, "No pricelist available for: "+reqItem.LotName)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Items = append(result.Items, item)
|
result.Items = append(result.Items, item)
|
||||||
|
|||||||
@@ -189,33 +189,54 @@ func listActiveClientMigrations(db *gorm.DB) ([]clientLocalMigration, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ensureClientMigrationRegistryTable(db *gorm.DB) error {
|
func ensureClientMigrationRegistryTable(db *gorm.DB) error {
|
||||||
if err := db.Exec(`
|
// Check if table exists instead of trying to create (avoids permission issues)
|
||||||
CREATE TABLE IF NOT EXISTS qt_client_local_migrations (
|
if !tableExists(db, "qt_client_local_migrations") {
|
||||||
id VARCHAR(128) NOT NULL,
|
if err := db.Exec(`
|
||||||
name VARCHAR(255) NOT NULL,
|
CREATE TABLE IF NOT EXISTS qt_client_local_migrations (
|
||||||
sql_text LONGTEXT NOT NULL,
|
id VARCHAR(128) NOT NULL,
|
||||||
checksum VARCHAR(128) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
min_app_version VARCHAR(64) NULL,
|
sql_text LONGTEXT NOT NULL,
|
||||||
order_no INT NOT NULL DEFAULT 0,
|
checksum VARCHAR(128) NOT NULL,
|
||||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
min_app_version VARCHAR(64) NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
order_no INT NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (id),
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
INDEX idx_qt_client_local_migrations_active_order (is_active, order_no, created_at)
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
)
|
PRIMARY KEY (id),
|
||||||
`).Error; err != nil {
|
INDEX idx_qt_client_local_migrations_active_order (is_active, order_no, created_at)
|
||||||
return err
|
)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("create qt_client_local_migrations table: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return db.Exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS qt_client_schema_state (
|
if !tableExists(db, "qt_client_schema_state") {
|
||||||
username VARCHAR(100) NOT NULL,
|
if err := db.Exec(`
|
||||||
last_applied_migration_id VARCHAR(128) NULL,
|
CREATE TABLE IF NOT EXISTS qt_client_schema_state (
|
||||||
app_version VARCHAR(64) NULL,
|
username VARCHAR(100) NOT NULL,
|
||||||
last_checked_at DATETIME NOT NULL,
|
last_applied_migration_id VARCHAR(128) NULL,
|
||||||
updated_at DATETIME NOT NULL,
|
app_version VARCHAR(64) NULL,
|
||||||
PRIMARY KEY (username),
|
last_checked_at DATETIME NOT NULL,
|
||||||
INDEX idx_qt_client_schema_state_checked (last_checked_at)
|
updated_at DATETIME NOT NULL,
|
||||||
)
|
PRIMARY KEY (username),
|
||||||
`).Error
|
INDEX idx_qt_client_schema_state_checked (last_checked_at)
|
||||||
|
)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("create qt_client_schema_state table: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableExists(db *gorm.DB, tableName string) bool {
|
||||||
|
var count int64
|
||||||
|
// For MariaDB/MySQL, check information_schema
|
||||||
|
if err := db.Raw(`
|
||||||
|
SELECT COUNT(*) FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
|
||||||
|
`, tableName).Scan(&count).Error; err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) applyMissingRemoteMigrations(migrations []clientLocalMigration) error {
|
func (s *Service) applyMissingRemoteMigrations(migrations []clientLocalMigration) error {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -346,17 +347,10 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
synced := 0
|
synced := 0
|
||||||
var latestEstimateLocalID uint
|
|
||||||
var latestEstimateCreatedAt time.Time
|
|
||||||
for _, pl := range serverPricelists {
|
for _, pl := range serverPricelists {
|
||||||
// Check if pricelist already exists locally
|
// Check if pricelist already exists locally
|
||||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
// Track latest estimate pricelist by created_at for component refresh.
|
|
||||||
if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) {
|
|
||||||
latestEstimateCreatedAt = pl.CreatedAt
|
|
||||||
latestEstimateLocalID = existing.ID
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,10 +379,6 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) {
|
|
||||||
latestEstimateCreatedAt = pl.CreatedAt
|
|
||||||
latestEstimateLocalID = localPL.ID
|
|
||||||
}
|
|
||||||
synced++
|
synced++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,15 +389,8 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
slog.Info("deleted stale local pricelists", "deleted", removed)
|
slog.Info("deleted stale local pricelists", "deleted", removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update component prices from latest estimate pricelist only.
|
// Backfill lot_category for used pricelists (older local caches may miss the column values).
|
||||||
if latestEstimateLocalID > 0 {
|
s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
|
||||||
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestEstimateLocalID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("failed to update component prices from pricelist", "error", err)
|
|
||||||
} else {
|
|
||||||
slog.Info("updated component prices from latest pricelist", "updated", updated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last sync time
|
// Update last sync time
|
||||||
s.localDB.SetLastSyncTime(time.Now())
|
s.localDB.SetLastSyncTime(time.Now())
|
||||||
@@ -417,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() {
|
||||||
@@ -553,24 +613,34 @@ func (s *Service) listConnectedDBUsers(mariaDB *gorm.DB) (map[string]struct{}, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ensureUserSyncStatusTable(db *gorm.DB) error {
|
func ensureUserSyncStatusTable(db *gorm.DB) error {
|
||||||
if err := db.Exec(`
|
// Check if table exists instead of trying to create (avoids permission issues)
|
||||||
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
|
if !tableExists(db, "qt_pricelist_sync_status") {
|
||||||
username VARCHAR(100) NOT NULL,
|
if err := db.Exec(`
|
||||||
last_sync_at DATETIME NOT NULL,
|
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
|
||||||
updated_at DATETIME NOT NULL,
|
username VARCHAR(100) NOT NULL,
|
||||||
app_version VARCHAR(64) NULL,
|
last_sync_at DATETIME NOT NULL,
|
||||||
PRIMARY KEY (username),
|
updated_at DATETIME NOT NULL,
|
||||||
INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at)
|
app_version VARCHAR(64) NULL,
|
||||||
)
|
PRIMARY KEY (username),
|
||||||
`).Error; err != nil {
|
INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at)
|
||||||
return err
|
)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("create qt_pricelist_sync_status table: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backward compatibility for environments where table was created without app_version.
|
// Backward compatibility for environments where table was created without app_version.
|
||||||
return db.Exec(`
|
// Only try to add column if table exists.
|
||||||
ALTER TABLE qt_pricelist_sync_status
|
if tableExists(db, "qt_pricelist_sync_status") {
|
||||||
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL
|
if err := db.Exec(`
|
||||||
`).Error
|
ALTER TABLE qt_pricelist_sync_status
|
||||||
|
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL
|
||||||
|
`).Error; err != nil {
|
||||||
|
// Log but don't fail if alter fails (column might already exist)
|
||||||
|
slog.Debug("failed to add app_version column", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncPricelistItems synchronizes items for a specific pricelist
|
// SyncPricelistItems synchronizes items for a specific pricelist
|
||||||
@@ -606,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 {
|
||||||
@@ -787,6 +849,12 @@ 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) == "" {
|
||||||
|
project.Code = strings.TrimSpace(derefString(project.Name))
|
||||||
|
if project.Code == "" {
|
||||||
|
project.Code = project.UUID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := projectRepo.UpsertByUUID(&project); err != nil {
|
if err := projectRepo.UpsertByUUID(&project); err != nil {
|
||||||
return fmt.Errorf("upsert project on server: %w", err)
|
return fmt.Errorf("upsert project on server: %w", err)
|
||||||
@@ -807,6 +875,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 != "" {
|
||||||
@@ -1077,7 +1156,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,
|
||||||
}
|
}
|
||||||
@@ -1241,6 +1321,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)
|
||||||
}
|
}
|
||||||
@@ -1248,6 +1343,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.
|
||||||
38
memory.md
Normal file
38
memory.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# 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).
|
||||||
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
|
||||||
72
releases/memory/v1.2.1.md
Normal file
72
releases/memory/v1.2.1.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# v1.2.1 Release Notes
|
||||||
|
|
||||||
|
**Date:** 2026-02-09
|
||||||
|
**Changes since v1.2.0:** 2 commits
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Fixed configurator component substitution by updating to work with new pricelist-based pricing model. Addresses regression from v1.2.0 refactor that removed `CurrentPrice` field from components.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
### 1. Refactor: Remove CurrentPrice from local_components (5984a57)
|
||||||
|
**Type:** Refactor
|
||||||
|
**Files Changed:** 11 files, +167 insertions, -194 deletions
|
||||||
|
|
||||||
|
#### Overview
|
||||||
|
Transitioned from component-based pricing to pricelist-based pricing model:
|
||||||
|
- Removed `CurrentPrice` and `SyncedAt` from LocalComponent (metadata-only now)
|
||||||
|
- Added `WarehousePricelistID` and `CompetitorPricelistID` to LocalConfiguration
|
||||||
|
- Removed 2 unused methods: UpdateComponentPricesFromPricelist, EnsureComponentPricesFromPricelists
|
||||||
|
|
||||||
|
#### Key Changes
|
||||||
|
- **Data Model:**
|
||||||
|
- LocalComponent: now stores only metadata (LotName, LotDescription, Category, Model)
|
||||||
|
- LocalConfiguration: added warehouse and competitor pricelist references
|
||||||
|
|
||||||
|
- **Migrations:**
|
||||||
|
- drop_component_unused_fields - removes CurrentPrice, SyncedAt columns
|
||||||
|
- add_warehouse_competitor_pricelists - adds new pricelist fields
|
||||||
|
|
||||||
|
- **Quote Calculation:**
|
||||||
|
- Updated to use pricelist_items instead of component.CurrentPrice
|
||||||
|
- Added PricelistID field to QuoteRequest
|
||||||
|
- Maintains offline-first behavior
|
||||||
|
|
||||||
|
- **API:**
|
||||||
|
- Removed CurrentPrice from ComponentView
|
||||||
|
- Components API no longer returns pricing
|
||||||
|
|
||||||
|
### 2. Fix: Load component prices via API (acf7c8a)
|
||||||
|
**Type:** Bug Fix
|
||||||
|
**Files Changed:** 1 file (web/templates/index.html), +66 insertions, -12 deletions
|
||||||
|
|
||||||
|
#### Problem
|
||||||
|
After v1.2.0 refactor, the configurator's autocomplete was filtering out all components because it checked for the removed `current_price` field on component objects.
|
||||||
|
|
||||||
|
#### Solution
|
||||||
|
Implemented on-demand price loading via API:
|
||||||
|
- Added `ensurePricesLoaded()` function to fetch prices from `/api/quote/price-levels`
|
||||||
|
- Added `componentPricesCache` to cache loaded prices in memory
|
||||||
|
- Updated all 3 autocomplete modes (single, multi, section) to load prices when input is focused
|
||||||
|
- Changed price validation from `c.current_price` to `hasComponentPrice(lot_name)`
|
||||||
|
- Updated cart item creation to use cached API prices
|
||||||
|
|
||||||
|
#### Impact
|
||||||
|
- Components without prices are still filtered out (as required)
|
||||||
|
- Price checks now use API data instead of removed database field
|
||||||
|
- Frontend loads prices on-demand for better performance
|
||||||
|
|
||||||
|
## Testing Notes
|
||||||
|
- ✅ Configurator component substitution now works
|
||||||
|
- ✅ Prices load correctly from pricelist
|
||||||
|
- ✅ Offline mode still supported (prices cached after initial load)
|
||||||
|
- ✅ Multi-pricelist support functional (estimate/warehouse/competitor)
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
None
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
No database migration needed from v1.2.0 - migrations were applied in v1.2.0 release.
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
None for end users. Internal: `ComponentView` no longer includes `CurrentPrice` in API responses.
|
||||||
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
|
||||||
89
releases/v1.2.1/RELEASE_NOTES.md
Normal file
89
releases/v1.2.1/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# QuoteForge v1.2.1
|
||||||
|
|
||||||
|
**Дата релиза:** 2026-02-09
|
||||||
|
**Тег:** `v1.2.1`
|
||||||
|
**GitHub:** https://git.mchus.pro/mchus/QuoteForge/releases/tag/v1.2.1
|
||||||
|
|
||||||
|
## Резюме
|
||||||
|
|
||||||
|
Быстрый патч-релиз, исправляющий регрессию в конфигураторе после рефактора v1.2.0. После удаления поля `CurrentPrice` из компонентов, autocomplete перестал показывать компоненты. Теперь используется на-demand загрузка цен через API.
|
||||||
|
|
||||||
|
## Что исправлено
|
||||||
|
|
||||||
|
### 🐛 Configurator Component Substitution (acf7c8a)
|
||||||
|
- **Проблема:** После рефактора в v1.2.0, autocomplete фильтровал ВСЕ компоненты, потому что проверял удаленное поле `current_price`
|
||||||
|
- **Решение:** Загрузка цен на-demand через `/api/quote/price-levels`
|
||||||
|
- Добавлен `componentPricesCache` для кэширования цен в памяти
|
||||||
|
- Функция `ensurePricesLoaded()` загружает цены при фокусе на поле поиска
|
||||||
|
- Все 3 режима autocomplete (single, multi, section) обновлены
|
||||||
|
- Компоненты без цен по-прежнему фильтруются (как требуется), но проверка использует API
|
||||||
|
- **Затронутые файлы:** `web/templates/index.html` (+66 строк, -12 строк)
|
||||||
|
|
||||||
|
## История v1.2.0 → v1.2.1
|
||||||
|
|
||||||
|
Всего коммитов: **2**
|
||||||
|
|
||||||
|
| Хеш | Автор | Сообщение |
|
||||||
|
|-----|-------|-----------|
|
||||||
|
| `acf7c8a` | Claude | fix: load component prices via API instead of removed current_price field |
|
||||||
|
| `5984a57` | Claude | refactor: remove CurrentPrice from local_components and transition to pricelist-based pricing |
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
✅ Configurator component substitution работает
|
||||||
|
✅ Цены загружаются корректно из pricelist
|
||||||
|
✅ Offline режим поддерживается (цены кэшируются после первой загрузки)
|
||||||
|
✅ Multi-pricelist поддержка функциональна (estimate/warehouse/competitor)
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
Нет критических изменений для конечных пользователей.
|
||||||
|
|
||||||
|
⚠️ **Для разработчиков:** `ComponentView` API больше не возвращает `CurrentPrice`.
|
||||||
|
|
||||||
|
## Миграция
|
||||||
|
|
||||||
|
Не требуется миграция БД — все миграции были применены в v1.2.0.
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Скачать и распаковать
|
||||||
|
tar xzf qfs-v1.2.1-darwin-arm64.tar.gz # для Apple Silicon
|
||||||
|
# или
|
||||||
|
tar xzf qfs-v1.2.1-darwin-amd64.tar.gz # для Intel Mac
|
||||||
|
|
||||||
|
# Снять ограничение Gatekeeper (если требуется)
|
||||||
|
xattr -d com.apple.quarantine ./qfs
|
||||||
|
|
||||||
|
# Запустить
|
||||||
|
./qfs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar xzf qfs-v1.2.1-linux-amd64.tar.gz
|
||||||
|
./qfs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Распаковать qfs-v1.2.1-windows-amd64.zip
|
||||||
|
# Запустить qfs.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Известные проблемы
|
||||||
|
|
||||||
|
Нет известных проблем на момент релиза.
|
||||||
|
|
||||||
|
## Поддержка
|
||||||
|
|
||||||
|
По вопросам обращайтесь: [@mchus](https://git.mchus.pro/mchus)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Отправлено с ❤️ через Claude Code*
|
||||||
@@ -285,6 +285,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');
|
||||||
|
|||||||
@@ -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,14 +883,25 @@ document.getElementById('configs-search').addEventListener('input', function(e)
|
|||||||
async function loadProjectsForConfigUI() {
|
async function loadProjectsForConfigUI() {
|
||||||
projectsCache = [];
|
projectsCache = [];
|
||||||
projectNameByUUID = {};
|
projectNameByUUID = {};
|
||||||
|
projectCodeByUUID = {};
|
||||||
|
projectVariantByUUID = {};
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/projects?status=all');
|
// Use /api/projects/all to get all projects without pagination
|
||||||
|
const resp = await fetch('/api/projects/all');
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
projectsCache = (data.projects || []);
|
// data is now a simple array of {uuid, name} objects
|
||||||
|
const allProjects = Array.isArray(data) ? data : (data.projects || []);
|
||||||
|
|
||||||
projectsCache.forEach(project => {
|
// For compatibility with rest of code, populate projectsCache but mainly use projectNameByUUID
|
||||||
projectNameByUUID[project.uuid] = project.name;
|
projectsCache = allProjects;
|
||||||
|
|
||||||
|
allProjects.forEach(project => {
|
||||||
|
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');
|
||||||
@@ -850,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,25 @@
|
|||||||
<!-- 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>
|
||||||
|
<span id="breadcrumb-config-name">Конфигуратор</span>
|
||||||
|
<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 +109,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 +338,79 @@ let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
|||||||
// State
|
// State
|
||||||
let configUUID = '{{.ConfigUUID}}';
|
let configUUID = '{{.ConfigUUID}}';
|
||||||
let configName = '';
|
let configName = '';
|
||||||
|
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;
|
||||||
|
configEl.textContent = configName || 'Конфигурация';
|
||||||
|
versionEl.textContent = 'v1';
|
||||||
|
}
|
||||||
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 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,
|
||||||
@@ -351,6 +430,8 @@ let priceLevelsRefreshTimer = null;
|
|||||||
let warehouseStockLotsByPricelist = new Map();
|
let warehouseStockLotsByPricelist = new Map();
|
||||||
let warehouseStockLoadSeq = 0;
|
let warehouseStockLoadSeq = 0;
|
||||||
let warehouseStockLoadsByPricelist = new Map();
|
let warehouseStockLoadsByPricelist = new Map();
|
||||||
|
let componentPricesCache = {}; // { lot_name: price } - caches prices loaded via API
|
||||||
|
let componentPricesCacheLoading = new Map(); // { category: Promise } - tracks ongoing price loads
|
||||||
|
|
||||||
// Autocomplete state
|
// Autocomplete state
|
||||||
let autocompleteInput = null;
|
let autocompleteInput = null;
|
||||||
@@ -607,7 +688,9 @@ 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;
|
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
|
||||||
@@ -629,6 +712,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) {
|
||||||
@@ -943,7 +1029,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>
|
||||||
@@ -1201,12 +1312,54 @@ function renderMultiSelectTabWithSections(sections) {
|
|||||||
document.getElementById('tab-content').innerHTML = html;
|
document.getElementById('tab-content').innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load prices for components in a category/tab via API
|
||||||
|
async function ensurePricesLoaded(components) {
|
||||||
|
if (!components || components.length === 0) return;
|
||||||
|
|
||||||
|
// Filter out components that already have prices cached
|
||||||
|
const toLoad = components.filter(c => !(c.lot_name in componentPricesCache));
|
||||||
|
if (toLoad.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use quote/price-levels API to get prices for these components
|
||||||
|
const resp = await fetch('/api/quote/price-levels', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: toLoad.map(c => ({ lot_name: c.lot_name, quantity: 1 })),
|
||||||
|
pricelist_ids: Object.fromEntries(
|
||||||
|
Object.entries(selectedPricelistIds)
|
||||||
|
.filter(([, id]) => typeof id === 'number' && id > 0)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.items) {
|
||||||
|
data.items.forEach(item => {
|
||||||
|
// Cache the estimate price (or 0 if not found)
|
||||||
|
componentPricesCache[item.lot_name] = item.estimate_price || 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load component prices', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasComponentPrice(lotName) {
|
||||||
|
return lotName in componentPricesCache && componentPricesCache[lotName] > 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Autocomplete for single select (Base tab)
|
// Autocomplete for single select (Base tab)
|
||||||
function showAutocomplete(category, input) {
|
async function showAutocomplete(category, input) {
|
||||||
autocompleteInput = input;
|
autocompleteInput = input;
|
||||||
autocompleteCategory = category;
|
autocompleteCategory = category;
|
||||||
autocompleteMode = 'single';
|
autocompleteMode = 'single';
|
||||||
autocompleteIndex = -1;
|
autocompleteIndex = -1;
|
||||||
|
const components = getComponentsForCategory(category);
|
||||||
|
await ensurePricesLoaded(components);
|
||||||
filterAutocomplete(category, input.value);
|
filterAutocomplete(category, input.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1215,7 +1368,7 @@ function filterAutocomplete(category, search) {
|
|||||||
const searchLower = search.toLowerCase();
|
const searchLower = search.toLowerCase();
|
||||||
|
|
||||||
autocompleteFiltered = components.filter(c => {
|
autocompleteFiltered = components.filter(c => {
|
||||||
if (!c.current_price) return false;
|
if (!hasComponentPrice(c.lot_name)) return false;
|
||||||
if (!isComponentAllowedByStockFilter(c)) return false;
|
if (!isComponentAllowedByStockFilter(c)) return false;
|
||||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||||
return text.includes(searchLower);
|
return text.includes(searchLower);
|
||||||
@@ -1298,12 +1451,13 @@ function selectAutocompleteItem(index) {
|
|||||||
|
|
||||||
const qtyInput = document.getElementById('qty-' + autocompleteCategory);
|
const qtyInput = document.getElementById('qty-' + autocompleteCategory);
|
||||||
const qty = parseInt(qtyInput?.value) || 1;
|
const qty = parseInt(qtyInput?.value) || 1;
|
||||||
|
const price = componentPricesCache[comp.lot_name] || 0;
|
||||||
|
|
||||||
cart.push({
|
cart.push({
|
||||||
lot_name: comp.lot_name,
|
lot_name: comp.lot_name,
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
unit_price: comp.current_price,
|
unit_price: price,
|
||||||
estimate_price: comp.current_price,
|
estimate_price: price,
|
||||||
warehouse_price: null,
|
warehouse_price: null,
|
||||||
competitor_price: null,
|
competitor_price: null,
|
||||||
delta_wh_estimate_abs: null,
|
delta_wh_estimate_abs: null,
|
||||||
@@ -1333,11 +1487,13 @@ function hideAutocomplete() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Autocomplete for multi select tabs
|
// Autocomplete for multi select tabs
|
||||||
function showAutocompleteMulti(input) {
|
async function showAutocompleteMulti(input) {
|
||||||
autocompleteInput = input;
|
autocompleteInput = input;
|
||||||
autocompleteCategory = null;
|
autocompleteCategory = null;
|
||||||
autocompleteMode = 'multi';
|
autocompleteMode = 'multi';
|
||||||
autocompleteIndex = -1;
|
autocompleteIndex = -1;
|
||||||
|
const components = getComponentsForTab(currentTab);
|
||||||
|
await ensurePricesLoaded(components);
|
||||||
filterAutocompleteMulti(input.value);
|
filterAutocompleteMulti(input.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1349,7 +1505,7 @@ function filterAutocompleteMulti(search) {
|
|||||||
const addedLots = new Set(cart.map(i => i.lot_name));
|
const addedLots = new Set(cart.map(i => i.lot_name));
|
||||||
|
|
||||||
autocompleteFiltered = components.filter(c => {
|
autocompleteFiltered = components.filter(c => {
|
||||||
if (!c.current_price) return false;
|
if (!hasComponentPrice(c.lot_name)) return false;
|
||||||
if (addedLots.has(c.lot_name)) return false;
|
if (addedLots.has(c.lot_name)) return false;
|
||||||
if (!isComponentAllowedByStockFilter(c)) return false;
|
if (!isComponentAllowedByStockFilter(c)) return false;
|
||||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||||
@@ -1390,12 +1546,13 @@ function selectAutocompleteItemMulti(index) {
|
|||||||
|
|
||||||
const qtyInput = document.getElementById('new-qty');
|
const qtyInput = document.getElementById('new-qty');
|
||||||
const qty = parseInt(qtyInput?.value) || 1;
|
const qty = parseInt(qtyInput?.value) || 1;
|
||||||
|
const price = componentPricesCache[comp.lot_name] || 0;
|
||||||
|
|
||||||
cart.push({
|
cart.push({
|
||||||
lot_name: comp.lot_name,
|
lot_name: comp.lot_name,
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
unit_price: comp.current_price,
|
unit_price: price,
|
||||||
estimate_price: comp.current_price,
|
estimate_price: price,
|
||||||
warehouse_price: null,
|
warehouse_price: null,
|
||||||
competitor_price: null,
|
competitor_price: null,
|
||||||
delta_wh_estimate_abs: null,
|
delta_wh_estimate_abs: null,
|
||||||
@@ -1417,11 +1574,16 @@ function selectAutocompleteItemMulti(index) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
|
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
|
||||||
function showAutocompleteSection(sectionId, input) {
|
async function showAutocompleteSection(sectionId, input) {
|
||||||
autocompleteInput = input;
|
autocompleteInput = input;
|
||||||
autocompleteCategory = sectionId; // Store section ID
|
autocompleteCategory = sectionId; // Store section ID
|
||||||
autocompleteMode = 'section';
|
autocompleteMode = 'section';
|
||||||
autocompleteIndex = -1;
|
autocompleteIndex = -1;
|
||||||
|
|
||||||
|
// Load prices for tab components
|
||||||
|
const components = getComponentsForTab(currentTab);
|
||||||
|
await ensurePricesLoaded(components);
|
||||||
|
|
||||||
filterAutocompleteSection(sectionId, input.value, input);
|
filterAutocompleteSection(sectionId, input.value, input);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1448,7 +1610,7 @@ function filterAutocompleteSection(sectionId, search, inputElement) {
|
|||||||
const addedLots = new Set(cart.map(i => i.lot_name));
|
const addedLots = new Set(cart.map(i => i.lot_name));
|
||||||
|
|
||||||
autocompleteFiltered = sectionComponents.filter(c => {
|
autocompleteFiltered = sectionComponents.filter(c => {
|
||||||
if (!c.current_price) return false;
|
if (!hasComponentPrice(c.lot_name)) return false;
|
||||||
if (addedLots.has(c.lot_name)) return false;
|
if (addedLots.has(c.lot_name)) return false;
|
||||||
if (!isComponentAllowedByStockFilter(c)) return false;
|
if (!isComponentAllowedByStockFilter(c)) return false;
|
||||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||||
@@ -1489,12 +1651,13 @@ function selectAutocompleteItemSection(index, sectionId) {
|
|||||||
|
|
||||||
const qtyInput = document.getElementById('new-qty-' + sectionId);
|
const qtyInput = document.getElementById('new-qty-' + sectionId);
|
||||||
const qty = parseInt(qtyInput?.value) || 1;
|
const qty = parseInt(qtyInput?.value) || 1;
|
||||||
|
const price = componentPricesCache[comp.lot_name] || 0;
|
||||||
|
|
||||||
cart.push({
|
cart.push({
|
||||||
lot_name: comp.lot_name,
|
lot_name: comp.lot_name,
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
unit_price: comp.current_price,
|
unit_price: price,
|
||||||
estimate_price: comp.current_price,
|
estimate_price: price,
|
||||||
warehouse_price: null,
|
warehouse_price: null,
|
||||||
competitor_price: null,
|
competitor_price: null,
|
||||||
delta_wh_estimate_abs: null,
|
delta_wh_estimate_abs: null,
|
||||||
@@ -1579,6 +1742,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>';
|
||||||
@@ -1654,6 +1819,69 @@ 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 triggerAutoSave() {
|
function triggerAutoSave() {
|
||||||
// Debounce autosave - wait 1 second after last change
|
// Debounce autosave - wait 1 second after last change
|
||||||
if (autoSaveTimeout) {
|
if (autoSaveTimeout) {
|
||||||
@@ -1694,6 +1922,9 @@ async function saveConfig(showNotification = true) {
|
|||||||
custom_price: customPrice,
|
custom_price: customPrice,
|
||||||
notes: '',
|
notes: '',
|
||||||
server_count: serverCountValue,
|
server_count: serverCountValue,
|
||||||
|
server_model: serverModelForQuote,
|
||||||
|
support_code: supportCode,
|
||||||
|
article: getCurrentArticle(),
|
||||||
pricelist_id: selectedPricelistIds.estimate,
|
pricelist_id: selectedPricelistIds.estimate,
|
||||||
only_in_stock: onlyInStock
|
only_in_stock: onlyInStock
|
||||||
})
|
})
|
||||||
@@ -1716,6 +1947,14 @@ async function saveConfig(showNotification = true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to extract filename from Content-Disposition header
|
||||||
|
function getFilenameFromResponse(resp) {
|
||||||
|
const contentDisposition = resp.headers.get('content-disposition');
|
||||||
|
if (!contentDisposition) return null;
|
||||||
|
const matches = contentDisposition.match(/filename="?([^"]+)"?/);
|
||||||
|
return matches && matches[1] ? matches[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
async function exportCSV() {
|
async function exportCSV() {
|
||||||
if (cart.length === 0) return;
|
if (cart.length === 0) return;
|
||||||
|
|
||||||
@@ -1730,17 +1969,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})
|
||||||
});
|
});
|
||||||
|
|
||||||
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 = (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) {
|
||||||
@@ -1986,14 +2227,14 @@ 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();
|
||||||
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 = (configName || 'config') + '.csv';
|
a.download = getFilenameFromResponse(resp) || (configName || 'config') + '.csv';
|
||||||
a.click();
|
a.click();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
|
|||||||
@@ -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,45 @@
|
|||||||
<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-4 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>
|
||||||
</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,6 +81,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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-lg 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>
|
||||||
|
<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">
|
||||||
|
</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 class="mt-6 flex justify-end gap-2">
|
||||||
|
<button onclick="closeNewVariantModal()" class="px-4 py-2 text-gray-700 bg-gray-100 rounded hover:bg-gray-200">Отмена</button>
|
||||||
|
<button onclick="createNewVariant()" class="px-4 py-2 text-white bg-purple-600 rounded hover:bg-purple-700">Создать</button>
|
||||||
|
</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="rename-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>
|
||||||
@@ -113,11 +163,46 @@
|
|||||||
</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 variantMenuInitialized = false;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
@@ -131,6 +216,91 @@ 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) {
|
||||||
|
if (!code) 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 || []);
|
||||||
|
projectVariants = allProjects
|
||||||
|
.filter(p => (p.code || '').trim() === code)
|
||||||
|
.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;
|
||||||
@@ -228,7 +398,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 +443,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');
|
||||||
@@ -397,6 +619,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;
|
||||||
@@ -478,14 +759,17 @@ function wildcardMatch(value, pattern) {
|
|||||||
|
|
||||||
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('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('clone-modal').addEventListener('click', function(e) { if (e.target === this) closeCloneModal(); });
|
||||||
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.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
closeCreateModal();
|
closeCreateModal();
|
||||||
closeRenameModal();
|
closeRenameModal();
|
||||||
closeCloneModal();
|
closeCloneModal();
|
||||||
closeImportModal();
|
closeImportModal();
|
||||||
|
closeProjectSettingsModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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