5 Commits
v1.6.1 ... main

Author SHA1 Message Date
Mikhail Chusavitin
8b5e04168a release: v1.8 2026-04-28 16:56:45 +03:00
Mikhail Chusavitin
61d23ef8c4 Fix pricelist sync upsert and refresh tests 2026-04-28 16:54:36 +03:00
11fd314a65 release: v1.7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:03:17 +03:00
e59a43c279 feat: унифицировать autocomplete для LOT на всех вкладках
- Все вкладки (storage, pci, power, accessories, sw, other) теперь
  используют редактируемый autocomplete-input для существующих позиций,
  как на вкладке base; выбор заменяет позицию с сохранением количества
- LOT-поле в BOM-таблицах переведено на общий autocomplete dropdown
  вместо datalist
- Кнопка ✕ в BOM снимает сопоставление вместо удаления строки
- Кнопка «Пересчитать эстимейт» переименована в «Перенести в эстимейт»

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:00:45 +03:00
Mikhail Chusavitin
83a3202bdf Restore RAID section for server storage tab 2026-04-16 09:28:14 +03:00
7 changed files with 401 additions and 82 deletions

View File

@@ -499,7 +499,7 @@ func TestToPricingCSV_UsesSelectedColumns(t *testing.T) {
if err != nil {
t.Fatalf("read summary row: %v", err)
}
expectedSummary := []string{"10", "", "", "Config A", "2", "2 400,50", "2 000,00", "1 800,25"}
expectedSummary := []string{"10", "", "ART-1", "Config A", "2", "2 400,50", "2 000,00", "1 800,25"}
for i, want := range expectedSummary {
if summary[i] != want {
t.Fatalf("summary[%d]: expected %q, got %q", i, want, summary[i])

View File

@@ -17,6 +17,7 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var ErrOffline = errors.New("database is offline")
@@ -357,6 +358,18 @@ func (s *Service) SyncPricelists() (int, error) {
// Check if pricelist already exists locally
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
if existing != nil {
existing.Source = pl.Source
existing.Version = pl.Version
existing.Name = pl.Notification
existing.CreatedAt = pl.CreatedAt
existing.SyncedAt = time.Now()
if err := s.localDB.SaveLocalPricelist(existing); err != nil {
if syncErr == nil {
syncErr = fmt.Errorf("refresh existing pricelist %s: %w", pl.Version, err)
}
slog.Warn("failed to refresh existing local pricelist header", "version", pl.Version, "error", err)
continue
}
// Backfill items for legacy/partial local caches where only pricelist metadata exists.
if s.localDB.CountLocalPricelistItems(existing.ID) == 0 {
itemCount, err := s.SyncPricelistItems(existing.ID)
@@ -468,24 +481,29 @@ func (s *Service) syncNewPricelistSnapshot(localPL *localdb.LocalPricelist) (int
}
if err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
if err := tx.Create(localPL).Error; err != nil {
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "server_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"source": localPL.Source,
"version": localPL.Version,
"name": localPL.Name,
"created_at": localPL.CreatedAt,
"synced_at": localPL.SyncedAt,
"is_used": localPL.IsUsed,
}),
}).Create(localPL).Error; err != nil {
return fmt.Errorf("save local pricelist: %w", err)
}
if len(localItems) == 0 {
return nil
if localPL.ID == 0 {
if err := tx.Where("server_id = ?", localPL.ServerID).First(localPL).Error; err != nil {
return fmt.Errorf("reload local pricelist: %w", err)
}
}
for i := range localItems {
localItems[i].PricelistID = localPL.ID
}
batchSize := 500
for i := 0; i < len(localItems); i += batchSize {
end := i + batchSize
if end > len(localItems) {
end = len(localItems)
}
if err := tx.CreateInBatches(localItems[i:end], batchSize).Error; err != nil {
return fmt.Errorf("save local pricelist items: %w", err)
}
if err := replaceLocalPricelistItemsTx(tx, localPL.ID, localItems); err != nil {
return fmt.Errorf("save local pricelist items: %w", err)
}
return nil
}); err != nil {
@@ -496,6 +514,27 @@ func (s *Service) syncNewPricelistSnapshot(localPL *localdb.LocalPricelist) (int
return len(localItems), nil
}
func replaceLocalPricelistItemsTx(tx *gorm.DB, pricelistID uint, items []localdb.LocalPricelistItem) error {
if err := tx.Where("pricelist_id = ?", pricelistID).Delete(&localdb.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
}
func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.PricelistRepository, activeServerPricelistIDs []uint) {
if s.localDB == nil || pricelistRepo == nil {
return

View File

@@ -0,0 +1,118 @@
package sync
import (
"path/filepath"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestSyncNewPricelistSnapshotUpsertsExistingServerID(t *testing.T) {
local := newLocalDBForUpsertTest(t)
serverDB := newServerDBForUpsertTest(t)
if err := serverDB.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}); err != nil {
t.Fatalf("migrate server pricelist tables: %v", err)
}
serverPL := models.Pricelist{
Source: "estimate",
Version: "B-2026-04-28-001",
Notification: "server-current",
CreatedBy: "tester",
IsActive: true,
CreatedAt: time.Now().Add(-1 * time.Hour),
}
if err := serverDB.Create(&serverPL).Error; err != nil {
t.Fatalf("create server pricelist: %v", err)
}
if err := serverDB.Create(&models.PricelistItem{PricelistID: serverPL.ID, LotName: "CPU_A", Price: 10}).Error; err != nil {
t.Fatalf("create server pricelist item: %v", err)
}
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: serverPL.ID,
Source: "estimate",
Version: "old-version",
Name: "stale-local",
CreatedAt: time.Now().Add(-24 * time.Hour),
SyncedAt: time.Now().Add(-24 * time.Hour),
IsUsed: false,
}); err != nil {
t.Fatalf("seed stale local pricelist: %v", err)
}
staleLocal, err := local.GetLocalPricelistByServerID(serverPL.ID)
if err != nil {
t.Fatalf("get stale local pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{PricelistID: staleLocal.ID, LotName: "OLD_LOT", Price: 99},
}); err != nil {
t.Fatalf("seed stale local pricelist items: %v", err)
}
svc := NewServiceWithDB(serverDB, local)
localPL := &localdb.LocalPricelist{
ServerID: serverPL.ID,
Source: serverPL.Source,
Version: serverPL.Version,
Name: serverPL.Notification,
CreatedAt: serverPL.CreatedAt,
SyncedAt: time.Now(),
IsUsed: false,
}
itemCount, err := svc.syncNewPricelistSnapshot(localPL)
if err != nil {
t.Fatalf("sync new pricelist snapshot: %v", err)
}
if itemCount != 1 {
t.Fatalf("expected 1 synced item, got %d", itemCount)
}
refreshed, err := local.GetLocalPricelistByServerID(serverPL.ID)
if err != nil {
t.Fatalf("get refreshed local pricelist: %v", err)
}
if refreshed.Version != serverPL.Version {
t.Fatalf("expected local version %q, got %q", serverPL.Version, refreshed.Version)
}
if refreshed.Name != serverPL.Notification {
t.Fatalf("expected local name %q, got %q", serverPL.Notification, refreshed.Name)
}
items, err := local.GetLocalPricelistItems(refreshed.ID)
if err != nil {
t.Fatalf("load refreshed local items: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 local item after refresh, got %d", len(items))
}
if items[0].LotName != "CPU_A" {
t.Fatalf("expected refreshed item CPU_A, got %q", items[0].LotName)
}
}
func newLocalDBForUpsertTest(t *testing.T) *localdb.LocalDB {
t.Helper()
localPath := filepath.Join(t.TempDir(), "local.db")
local, err := localdb.New(localPath)
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
return local
}
func newServerDBForUpsertTest(t *testing.T) *gorm.DB {
t.Helper()
serverPath := filepath.Join(t.TempDir(), "server.db")
db, err := gorm.Open(sqlite.Open(serverPath), &gorm.Config{})
if err != nil {
t.Fatalf("open server sqlite: %v", err)
}
return db
}

View File

@@ -434,54 +434,14 @@ func newServerDBForSyncTest(t *testing.T) *gorm.DB {
if err != nil {
t.Fatalf("open server sqlite: %v", err)
}
if err := db.Exec(`
CREATE TABLE qt_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
owner_username TEXT NOT NULL,
code TEXT NOT NULL,
variant TEXT NOT NULL DEFAULT '',
name TEXT NOT 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
);`).Error; err != nil {
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(`
CREATE TABLE qt_configurations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
user_id INTEGER NULL,
owner_username TEXT NOT NULL,
project_uuid TEXT NULL,
app_version TEXT NULL,
name TEXT NOT NULL,
items TEXT NOT NULL,
total_price REAL NULL,
custom_price REAL NULL,
notes TEXT NULL,
is_template INTEGER NOT NULL DEFAULT 0,
server_count INTEGER NOT NULL DEFAULT 1,
server_model TEXT NULL,
support_code TEXT NULL,
article TEXT NULL,
pricelist_id INTEGER NULL,
warehouse_pricelist_id INTEGER NULL,
competitor_pricelist_id INTEGER NULL,
disable_price_refresh INTEGER NOT NULL DEFAULT 0,
only_in_stock INTEGER NOT NULL DEFAULT 0,
line_no INTEGER NULL,
price_updated_at DATETIME NULL,
vendor_spec TEXT NULL,
created_at DATETIME
);`).Error; err != nil {
t.Fatalf("create qt_configurations: %v", err)
if err := db.AutoMigrate(
&models.Project{},
&models.Configuration{},
&models.Pricelist{},
&models.PricelistItem{},
&models.Lot{},
); err != nil {
t.Fatalf("migrate server test schema: %v", err)
}
return db
}

View File

@@ -0,0 +1,13 @@
# QuoteForge v1.7
Дата релиза: 2026-04-23
Тег: `v1.7`
Предыдущий релиз: `v1.6.2`
## Ключевые изменения
- все вкладки estimate (storage, pci, power, accessories, sw, other) теперь используют редактируемый autocomplete-input для существующих позиций — поведение идентично вкладке base;
- LOT-поля в BOM-таблицах переведены на общий autocomplete dropdown вместо datalist;
- кнопка ✕ в BOM снимает сопоставление BOM→LOT вместо удаления строки;
- кнопка «Пересчитать эстимейт» переименована в «Перенести в эстимейт».

View File

@@ -0,0 +1,13 @@
# QuoteForge v1.8
Дата релиза: 2026-04-28
Тег: `v1.8`
Предыдущий релиз: `v1.7`
## Ключевые изменения
- исправлен sync прайслистов при конфликте `local_pricelists.server_id`: сохранение локального снапшота стало idempotent через upsert;
- сохранение нового локального снапшота прайслиста теперь атомарно заменяет строки внутри одной транзакции;
- sync обновляет метаданные уже существующих локальных прайслистов;
- устаревшие sync/export тесты приведены к актуальному контракту, `go test ./...` проходит полностью.

View File

@@ -194,7 +194,7 @@
Сохранить BOM
</button>
<button onclick="applyBOMToEstimate()" class="px-3 py-1 bg-orange-600 text-white rounded hover:bg-orange-700">
Пересчитать эстимейт
Перенести в эстимейт
</button>
</div>
</div>
@@ -378,6 +378,7 @@ let TAB_CONFIG = {
singleSelect: false,
label: 'Storage',
sections: [
{ title: 'RAID Контроллеры', categories: ['RAID'] },
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
]
},
@@ -541,7 +542,8 @@ let componentPricesCacheLoading = new Map(); // { category: Promise } - tracks o
// Autocomplete state
let autocompleteInput = null;
let autocompleteCategory = null;
let autocompleteMode = null; // 'single', 'multi', 'section'
let autocompleteMode = null; // 'single', 'multi', 'section', 'edit-item'
let autocompleteEditCategories = null;
let autocompleteIndex = -1;
let autocompleteFiltered = [];
@@ -1166,6 +1168,7 @@ function applyConfigTypeToTabs() {
const baseCategories = ['MB', 'CPU', 'MEM', 'DKC', 'CTL', 'ENC'];
const storageCategories = ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'];
const storageSections = [
{ title: 'RAID Контроллеры', categories: ['RAID'] },
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
];
const pciCategories = ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC'];
@@ -1187,7 +1190,12 @@ function applyConfigTypeToTabs() {
TAB_CONFIG.storage.categories = storageCategories.filter(c => {
return configType === 'storage' ? !STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(c) : true;
});
TAB_CONFIG.storage.sections = storageSections;
TAB_CONFIG.storage.sections = storageSections.filter(section => {
if (configType === 'storage') {
return !section.categories.every(cat => STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(cat));
}
return true;
});
TAB_CONFIG.pci.categories = pciCategories.filter(c => {
return configType === 'storage' ? !STORAGE_HIDDEN_PCI_CATEGORIES.includes(c) : c !== 'HIC';
@@ -1382,7 +1390,16 @@ function renderMultiSelectTab(components) {
html += `
<tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-2 min-w-48">
<div class="autocomplete-wrapper relative">
<input type="text"
value="${escapeHtml(item.lot_name)}"
class="w-full px-2 py-1 border rounded text-sm font-mono"
onfocus="showAutocompleteEditItem('${item.lot_name}', this)"
oninput="filterAutocompleteEditItem(this.value)"
onkeydown="handleAutocompleteKeyEditItem(event)">
</div>
</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
<td class="px-3 py-2 text-center">
@@ -1476,6 +1493,10 @@ function renderMultiSelectTabWithSections(sections) {
<tbody class="divide-y">
`;
// Add empty row for new item in this section
const sectionId = section.categories.join('-');
const categoriesStr = section.categories.join(',');
// Render existing cart items for this section
sectionItems.forEach((item) => {
const comp = allComponents.find(c => c.lot_name === item.lot_name);
@@ -1483,7 +1504,17 @@ function renderMultiSelectTabWithSections(sections) {
html += `
<tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-2 min-w-48">
<div class="autocomplete-wrapper relative">
<input type="text"
value="${escapeHtml(item.lot_name)}"
data-categories="${categoriesStr}"
class="w-full px-2 py-1 border rounded text-sm font-mono"
onfocus="showAutocompleteEditItem('${item.lot_name}', this)"
oninput="filterAutocompleteEditItem(this.value)"
onkeydown="handleAutocompleteKeyEditItem(event)">
</div>
</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
<td class="px-3 py-2 text-center">
@@ -1504,8 +1535,6 @@ function renderMultiSelectTabWithSections(sections) {
});
// Add empty row for new item in this section
const sectionId = section.categories.join('-');
const categoriesStr = section.categories.join(',');
html += `
<tr class="hover:bg-gray-50 bg-gray-50">
<td class="px-3 py-2" colspan="2">
@@ -1633,6 +1662,10 @@ function renderAutocomplete() {
onmousedown = `selectAutocompleteItemSection(${idx}, '${autocompleteCategory}')`;
} else if (autocompleteMode === 'multi') {
onmousedown = `selectAutocompleteItemMulti(${idx})`;
} else if (autocompleteMode === 'bom') {
onmousedown = `selectAutocompleteItemBOM(${idx}, ${autocompleteCategory})`;
} else if (autocompleteMode === 'edit-item') {
onmousedown = `selectAutocompleteEditItem(${idx})`;
} else {
// single mode
onmousedown = `selectAutocompleteItem(${idx})`;
@@ -1914,6 +1947,138 @@ function selectAutocompleteItemSection(index, sectionId) {
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
}
// Autocomplete for editing an existing cart item's LOT (multi/section tabs)
async function showAutocompleteEditItem(lotName, input) {
autocompleteInput = input;
autocompleteCategory = lotName;
autocompleteMode = 'edit-item';
autocompleteIndex = -1;
autocompleteEditCategories = input.dataset.categories
? input.dataset.categories.split(',').map(c => c.trim().toUpperCase())
: null;
const components = getComponentsForTab(currentTab);
await ensurePricesLoaded(components);
filterAutocompleteEditItem(input.value);
}
function filterAutocompleteEditItem(search) {
const searchLower = (search || '').toLowerCase();
const components = autocompleteEditCategories
? allComponents.filter(c => autocompleteEditCategories.includes(getComponentCategory(c)))
: getComponentsForTab(currentTab);
autocompleteFiltered = components.filter(c => {
if (!hasComponentPrice(c.lot_name)) return false;
if (!isComponentAllowedByStockFilter(c)) return false;
return (c.lot_name + ' ' + (c.description || '')).toLowerCase().includes(searchLower);
}).sort((a, b) => {
const d = (b.popularity_score || 0) - (a.popularity_score || 0);
return d !== 0 ? d : a.lot_name.localeCompare(b.lot_name);
});
renderAutocomplete();
}
function handleAutocompleteKeyEditItem(event) {
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteEditItem(autocompleteIndex);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
}
function selectAutocompleteEditItem(index) {
const comp = autocompleteFiltered[index];
if (!comp) return;
const lotName = autocompleteCategory;
const oldItem = cart.find(i => i.lot_name === lotName);
const qty = oldItem?.quantity || 1;
cart = cart.filter(i => i.lot_name !== lotName);
const price = componentPricesCache[comp.lot_name] || 0;
cart.push({
lot_name: comp.lot_name,
quantity: qty,
unit_price: price,
estimate_price: price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
delta_wh_estimate_pct: null,
delta_comp_estimate_abs: null,
delta_comp_estimate_pct: null,
delta_comp_wh_abs: null,
delta_comp_wh_pct: null,
price_missing: ['warehouse', 'competitor'],
description: comp.description || '',
category: getComponentCategory(comp)
});
hideAutocomplete();
renderTab();
updateCartUI();
triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
}
// Autocomplete for BOM LOT mapping
function showAutocompleteBOM(rowIdx, input) {
autocompleteInput = input;
autocompleteCategory = rowIdx;
autocompleteMode = 'bom';
autocompleteIndex = -1;
filterAutocompleteBOM(rowIdx, input.value);
}
function filterAutocompleteBOM(rowIdx, search) {
const searchLower = (search || '').toLowerCase();
autocompleteFiltered = (window._bomAllComponents || allComponents).filter(c => {
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
}).sort((a, b) => {
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
if (popDiff !== 0) return popDiff;
return a.lot_name.localeCompare(b.lot_name);
});
renderAutocomplete();
}
function handleAutocompleteKeyBOM(event, rowIdx) {
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteItemBOM(autocompleteIndex, rowIdx);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
}
function selectAutocompleteItemBOM(index, rowIdx) {
const comp = autocompleteFiltered[index];
if (!comp) return;
const row = bomRows.find(r => r.source_row_index === rowIdx) || bomRows[rowIdx];
if (!row) return;
row.manual_lot = comp.lot_name;
hideAutocomplete();
resolveBOM();
}
function clearSingleSelect(category) {
cart = cart.filter(item =>
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== category.toUpperCase()
@@ -2961,6 +3126,18 @@ function deleteBOMRawRow(rowIdx) {
rebuildBOMRowsFromRaw();
}
function clearBOMLotMapping(rowIdx) {
const row = bomRows.find(r => r.source_row_index === rowIdx);
if (!row) return;
row.manual_lot = '';
row.resolved_lot = '';
row.resolution_source = 'unresolved';
row.lot_allocations = [];
row.bundle_enabled = false;
renderBOMTable();
debouncedResolveBOM();
}
function _bomRawLotCell(rowIdx) {
if (!bomImportRaw || bomImportRaw.mode !== 'raw') return '—';
if (bomImportRaw.ignoredRows?.[rowIdx]) return '<span class="text-gray-400">—</span>';
@@ -2982,13 +3159,12 @@ function _bomRawLotCell(rowIdx) {
if (isUnresolved) {
const val = map.manual_lot || '';
const invalid = val && !_bomLotValid(val);
return `<input type="text" placeholder="LOT..." value="${escapeHtml(val)}"
class="w-full min-w-28 px-2 py-1 border rounded text-xs ${invalid ? 'border-red-400 bg-red-50' : ''}"
list="lot-autocomplete-list"
oninput="setBOMManualLotDraft(${rowIdx}, this.value, this)"
onchange="commitBOMManualLot(${rowIdx}, this)"
onblur="commitBOMManualLot(${rowIdx}, this)">
return `<div class="autocomplete-wrapper relative"><input type="text" placeholder="Введите артикул..."
value="${escapeHtml(val)}"
class="w-full min-w-28 px-2 py-1 border rounded text-xs font-mono"
onfocus="showAutocompleteBOM(${rowIdx}, this)"
oninput="filterAutocompleteBOM(${rowIdx}, this.value); setBOMManualLotDraft(${rowIdx}, this.value, this)"
onkeydown="handleAutocompleteKeyBOM(event, ${rowIdx})"></div>
${renderBOMLotAllocationsEditor(rowIdx)}`;
}
let suffix = '';
@@ -3372,12 +3548,12 @@ function _renderBOMParsedTable() {
let lotCell = '';
if (isUnresolved) {
lotCell = `<input type="text" placeholder="Введите LOT..." value="${escapeHtml(row.manual_lot || '')}"
class="w-full px-2 py-1 border rounded text-sm focus:ring-1 focus:ring-blue-400"
oninput="bomRows[${idx}].manual_lot = this.value; this.classList.toggle('border-red-400', this.value && !_bomLotValid(this.value));"
onchange="if(_bomLotValid(this.value)){bomRows[${idx}].manual_lot=this.value;resolveBOM(); this.classList.remove('border-red-400');}else{this.value=bomRows[${idx}].manual_lot||'';}"
onblur="if(this.value && !_bomLotValid(this.value)){this.value=bomRows[${idx}].manual_lot||'';}"
list="lot-autocomplete-list">${renderBOMLotAllocationsEditor(idx)}`;
lotCell = `<div class="autocomplete-wrapper relative"><input type="text" placeholder="Введите артикул..."
value="${escapeHtml(row.manual_lot || '')}"
class="w-full px-2 py-1 border rounded text-sm font-mono focus:ring-1 focus:ring-blue-400"
onfocus="showAutocompleteBOM(${row.source_row_index}, this)"
oninput="filterAutocompleteBOM(${row.source_row_index}, this.value); bomRows.find(r=>r.source_row_index===${row.source_row_index}).manual_lot=this.value;"
onkeydown="handleAutocompleteKeyBOM(event, ${row.source_row_index})"></div>${renderBOMLotAllocationsEditor(idx)}`;
} else {
let suffix = '';
if (qtyMismatch) suffix = ` <span class="text-yellow-600 text-xs">≠est(${cartQty})</span>`;
@@ -3467,7 +3643,7 @@ function _renderBOMRawTable() {
<td class="w-12 px-1 py-1 border-b text-center align-top whitespace-nowrap">
<button type="button" title="Добавить LOT в bundle" onclick="addBOMAllocation(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-blue-600">+</button>
<button type="button" title="${ignored ? 'Не игнорировать' : 'Игнорировать'}" onclick="toggleBOMRawRowIgnored(${rowIdx})" class="inline-block text-xs px-1 ${ignored ? 'text-blue-600' : 'text-gray-400 hover:text-gray-700'}">${ignored ? '◉' : '○'}</button>
<button type="button" title="Удалить строку" onclick="deleteBOMRawRow(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-red-600">✕</button>
<button type="button" title="Снять сопоставление" onclick="clearBOMLotMapping(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-red-600">✕</button>
</td>`;
tbody.appendChild(tr);
});