- Migrations 026-028: qt_partnumber_books + qt_partnumber_book_items tables; is_primary_pn on lot_partnumbers; version VARCHAR(30); description VARCHAR(10000) on items (required by QuoteForge sync) - Service: CreateSnapshot expands bundles, filters empty lot_name and ignored PNs, copies description, activates new book atomically, applies GFS retention (7d/5w/12m/10y) with explicit item deletion - Task type TaskTypePartnumberBookCreate; handlers ListPartnumberBooks and CreatePartnumberBook; routes GET/POST /api/admin/pricing/partnumber-books - UI: snapshot list + "Создать снапшот сопоставлений" button with progress polling on /vendor-mappings page - Bible: history, api, background-tasks, vendor-mapping updated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
9.4 KiB
Change History
Architectural decisions and significant refactoring are recorded here. Every architectural decision MUST be documented in this file.
2026-02-21: Partnumber Book Snapshots for QuoteForge
Decision
Implemented versioned snapshots of the lot_partnumbers → LOT mapping in qt_partnumber_books / qt_partnumber_book_items. PriceForge writes; QuoteForge reads (SELECT only).
What changed
- Migration
026: addedis_primary_pn TINYINT(1) DEFAULT 1tolot_partnumbers; createdqt_partnumber_booksandqt_partnumber_book_itemstables (versionVARCHAR(20), later corrected). - Migration
027: correctedversion VARCHAR(20) → VARCHAR(30)—PNBOOK-YYYY-MM-DD-NNNis 21 chars and overflowed the original column. - Migration
028: addeddescription VARCHAR(10000) NULLtoqt_partnumber_book_items— required by QuoteForge sync (SELECT partnumber, lot_name, is_primary_pn, description). - Models
PartnumberBook,PartnumberBookItem(withDescription *string) added tointernal/models/lot.go;IsPrimaryPN booladded toLotPartnumber. - Service
internal/services/partnumber_book.go:CreateSnapshot: expands bundles (QuoteForge is bundle-unaware), copiesdescriptionfromlot_partnumbersto every expanded row, generates versionPNBOOK-YYYY-MM-DD-NNN, deactivates previous books and activates new one atomically, then runs GFS retention cleanup.expandMappings: filters out rows wherelot_nameis empty/whitespace; filters out partnumbers markedis_ignored = trueinqt_vendor_partnumber_seen. Only valid PN→LOT pairs enter the snapshot.applyRetention: deletes items explicitly (DELETE … WHERE book_id IN (…)) before deleting books — does not rely on FK CASCADE which GORM does not trigger on batch deletes.ListBooks: returns all snapshots ordered newest-first with item counts.
- GFS retention policy: 7 daily · 5 weekly · 12 monthly · 10 yearly; applied automatically after each snapshot; active book is never deleted.
- Task type
TaskTypePartnumberBookCreateadded tointernal/tasks/task.go. - Handlers
ListPartnumberBooksandCreatePartnumberBookadded tointernal/handlers/pricing.go;PartnumberBookServiceinjected via constructor. - Routes
GET /api/admin/pricing/partnumber-booksandPOST /api/admin/pricing/partnumber-booksregistered incmd/pfs/main.go. - UI: "Снимки сопоставлений (Partnumber Books)" section with snapshot table, progress bar, and "Создать снапшот сопоставлений" button added to
web/templates/vendor_mappings.html.
Rationale
QuoteForge needs a stable, versioned copy of the PN→LOT mapping to resolve BOM line items without live dependency on PriceForge. Snapshots decouple the two systems.
Constraints
- Bundles MUST be expanded: QuoteForge does not know about
qt_lot_bundles. - Snapshot rows with empty
lot_nameoris_ignored = truepartnumbers MUST be excluded. descriptionin book items comes fromlot_partnumbers.description; for expanded bundle rows the description of the parent partnumber mapping is used.is_primary_pnis copied fromlot_partnumbersinto each book item; drives qty logic in QuoteForge.- New snapshot is immediately
is_active=1; all previous books are deactivated in the same transaction. - Version format:
PNBOOK-YYYY-MM-DD-NNN(VARCHAR(30)), sequential within the same day. - Item deletion during retention MUST be explicit (items first, then books) — FK CASCADE is unreliable with GORM batch deletes.
- QuoteForge has
SELECTpermission only onqt_partnumber_booksandqt_partnumber_book_items.
Files
- Migrations:
026_add_partnumber_books.sql,027_fix_partnumber_books_version_length.sql,028_add_description_to_partnumber_book_items.sql - Models:
internal/models/lot.go - Service:
internal/services/partnumber_book.go - Handler:
internal/handlers/pricing.go - Tasks:
internal/tasks/task.go - Routes:
cmd/pfs/main.go - UI:
web/templates/vendor_mappings.html
2026-02-20: Pricelist Formation Hardening (Estimate/Warehouse/Meta)
Decision
Hardened pricelist formation rules to remove ambiguity and prevent silent data loss in UI/API.
What changed
estimatesnapshot now explicitly excludes hidden components (qt_lot_metadata.is_hidden = 1).- Category snapshot in
qt_pricelist_items.lot_categorynow always has a deterministic fallback:PART_is assigned even when a LOT row is missing inlot. PricelistItemJSON contract was normalized to a single category field (LotCategory -> json:"category"), removing duplicate JSON-tag ambiguity.- Meta-price source expansion now always includes the base LOT, then merges
meta_pricessources with deduplication (exact + wildcard overlaps).
Rationale
- Prevent hidden/ignored components from leaking into estimate pricelists.
- Keep frontend category rendering stable for all items.
- Avoid non-deterministic JSON serialization when duplicate tags are present.
- Ensure meta-article pricing includes self-history and does not overcount duplicate sources.
Constraints
estimatepricelist invariant:current_price > 0 AND is_hidden = 0.categoryin API must map from persistedqt_pricelist_items.lot_category.- Missing category source must default to
PART_. - Meta source list must contain each LOT at most once.
Files
- Model:
internal/models/pricelist.go - Estimate/Warehouse snapshot service:
internal/services/pricelist/service.go - Pricing handler/meta expansion:
internal/handlers/pricing.go - Pricing service/meta expansion:
internal/services/pricing/service.go - Tests:
internal/services/pricelist/service_estimate_test.gointernal/services/pricelist/service_warehouse_test.gointernal/handlers/pricing_meta_expand_test.gointernal/services/pricing/service_meta_test.gointernal/services/stock_import_test.go
2026-02-20: Seen Registry Deduplication by Partnumber
Decision
Changed qt_vendor_partnumber_seen semantics to one row per partnumber (vendor/source are no longer part of uniqueness).
Rationale
- Eliminates duplicate seen rows when the same partnumber appears both with vendor and without vendor.
- Keeps ignore behavior consistent regardless of vendor presence.
- Simplifies operational cleanup and prevents re-creation of vendor/no-vendor duplicates.
Constraints
partnumberis now the unique key in seen registry.- Ignore checks are resolved by
partnumberonly. - Stock provenance must be preserved (
source_type='stock') when stock data exists for the partnumber.
Files
- Migration:
migrations/025_dedup_vendor_seen_by_partnumber.sql - Service:
internal/services/stock_import.go - Service:
internal/services/vendor_mapping.go - Model:
internal/models/lot.go
2026-02-18: Global Vendor Partnumber Mapping
Decision
Implemented global vendor partnumber → LOT mapping with bundle support and seen-based ignore logic.
Key rules
lot_partnumbersis the canonical mapping contract (1:1 per key).- Composite mappings use internal bundle tables (
qt_lot_bundles,qt_lot_bundle_items). - Ignore logic moved from
stock_ignore_rulestoqt_vendor_partnumber_seen.is_ignored. - Resolver order: exact
(vendor, partnumber)→ fallback(vendor='', partnumber).
Rationale
- Preserves external/client contracts (
lot_partnumbers, LOT-based pricelists). - Avoids multi-row ambiguity in
lot_partnumbers. - Supports complex assembled vendor SKUs without client-side changes.
- Centralizes ignore behavior across all sources via seen-registry.
Constraints
- Bundle LOT is internal and must stay hidden in regular LOT list by default.
- Resolver order is mandatory and fixed.
- Bundle allocation for missing estimate: fallback from previous active warehouse pricelist; if absent →
0.
Files
- Migration:
migrations/023_vendor_partnumber_global_mapping.sql - Backfill:
migrations/024_backfill_vendor_seen_from_stock_and_ignore.sql - Resolver:
internal/lotmatch/matcher.go - Service:
internal/services/vendor_mapping.go - Warehouse calc:
internal/warehouse/snapshot.go - Stock import:
internal/services/stock_import.go - API:
internal/handlers/pricing.go,cmd/pfs/main.go
2026-02-10: LOT Page Refactoring
Decision
Moved LOT management to a dedicated /lot page. Removed LOT tab from Pricing Admin.
What changed
- Created
web/templates/lot.html— two tabs: LOT (component management) and Mappings (partnumber ↔ LOT). - Removed LOT tab from
admin_pricing.html; default tab changed toestimate. - Removed "Сопоставление partnumber → LOT" section from Warehouse tab.
- Updated navigation: LOT →
/lot, Pricing Admin →/admin/pricing. - Added
Lot()handler ininternal/handlers/web.go. - Added
/lotroute incmd/pfs/main.go.
Rationale
Separation of concerns: LOT/component management is distinct from pricing administration.
Warehouse Pricing: Weighted Average
Decision
Warehouse pricelist uses weighted_avg (quantity-weighted average) as the sole price calculation method.
Commit edff712 switched from weighted_median to weighted_avg.
price_methodfield always contains"weighted_avg".- No
price_period_days,price_coefficient,manual_price,meta_pricesin warehouse pricelists.
Architecture Conventions
All future architectural decisions must be documented here with:
- Date
- Decision summary
- Rationale
- Constraints / invariants
- Affected files