EdeeShop · Implementace

Systémové řazení entit

Upraveno

Systémové řazení entit spočívá v automatické tvorbě spojového seznamu pro určení pořadí entit. Výpočet pořadí a přerovnání listu je složitá a výpočetně náročná operace.

Pokud je priorita stanovena na straně ERP či můstku je vhodné systémové zpracování priorit vypnout, po vypnutí priorit pro konkrétní entitu již není možné provádět D&D operace a změnit pořadí entit v AR.

Entity s prioritou

EntitaPopis
Brandpořadí v seznamu značek
Categorypořadí kategorií v rámci nadřazené kategorie
ProductCategorypořadí produktů v dané kategorii
ProductGrouppořadí produktů v dané skupině
Productglobální pořadí produktů (v případě zobrazení seznamu produktů bez vazby na konkrétní kategorii nebo skupinu)
Grouppořadí skupin při výpisu skupin
ParameterTypepořadí parametrů
ParameterItempořadí hodnot parametrů (nelze importovat z externího systému momentálně, vždy lze definovat pouze v edeem)
ParameterGrouppořadí skupin parametrů
Stockpořadí skladů
ProductInSetpořadí položek setu v rámci setu
ProductVariantpořadí variant v rámci masteru
PaymentMethodpořadí platebních metod
ShippingMethodpořadí doprav
PriceListpořadí ceníků (má smysl pouze pokud se cena vyhodnocuje na základě pořadí ceníků a ne nějakým jiným mechanismem - třeba nejnižší cenou)
PickupPointpořadí odběrných míst pro osobní odběr

Vypnutí systémového řazení entit

Vypnutí systémového řazení entit je možné pouze pomocí Java kódu v konkrétní projektové implementaci. Vypnout řazení můžeme pro všechny entity, nebo entity můžeme specifikovat.

Vypnutí systémového řazení pro konkrétní entitu

java
public class ProjectFeature extends AbstractBaseEdeeShopFeature<EdeeShopModuleConfig> {    @Override    public void afterSpringInitialization(AbstractRefreshableApplicationContext moduleContext) {        super.afterSpringInitialization(moduleContext);        PriorityService priorityService = moduleContext.getBean(SharedBeanNames.PRIORITY_SERVICE, PriorityService.class);        priorityService.disableDefaultPriorityManagement(Category.ENTITY_NAME);    }}

Vypnutí systémového řazení pro všechny entity

java
public class ProjectFeature extends AbstractBaseEdeeShopFeature<EdeeShopModuleConfig> {    @Override    public void afterSpringInitialization(AbstractRefreshableApplicationContext moduleContext) {        super.afterSpringInitialization(moduleContext);        PriorityService priorityService = moduleContext.getBean(SharedBeanNames.PRIORITY_SERVICE, PriorityService.class);        priorityService.disableDefaultPriorityManagement();    }}

Implementační detaily

Datový model

Každá entita zařazená do systémového řazení implementuje rozhraní WithPriority a v tabulce má dva sloupce:

  • priority (long) — relativní pořadí v rámci nadřazeného klíče. Vyšší hodnota = výše v seznamu. Hodnota -1 (NO_PRIORITY) znamená, že entita ze spojového seznamu vypadla (TRASHED, ukončený prodej, varianta produktu absorbovaná pod jiný master atd.).
  • previousEntity (int, FK na stejnou tabulku, UNIQUE) — id předchůdce ve spojovém seznamu. První entita má previousEntity = NULL. Unikátní omezení zaručuje, že každá entita má nejvýše jednoho následníka v rámci jednoho parent klíče.

Skupinu, v níž se entity řadí, definují parentKeys v MovePrioritySettings (např. idCatalog pro Product, idParameterType pro ParameterItemInType, idMasterProduct pro variantu). Spojový seznam vždy existuje samostatně pro každou kombinaci parentKeys.

Konfigurace

Konfigurace se registruje při startu kontextu (typicky v *PriorityDao.initPrioritySettings()) a sestává ze dvou objektů:

  • MovePrioritySettings — strukturální popis (řídí se podle entity, nemění se za běhu): parentKeys, joinTable, createJournal, publishingEntityType, publishingType, additionalCondition, callbacky pro kaskádní akce a volitelný createJournalPublishPreviousCatalogEntityFunction pro custom SQL publikace.
  • MovePriorityBehaviorSettings — runtime přepínače (lze měnit za běhu — kód i testy toho využívají): viz Přepínače chování.

Dále PriorityService udržuje seznam equivalentViews (např. ParameterItem ↔ ParameterItemInType) — toggle aplikovaný na jeden název prosakuje na všechny ekvivalenty.

Interceptory

MovePriorityPersistenceInterceptor (základní)

Sedí na třech metadatových hookcích pro každou entitu s MovePrioritySettings:

  1. intercept(MethodType.StoreContainer, container) — beforeStore. Podle stavu a vstupních příznaků zvolí jednu z větví v beforeStore:

    • trash / force-unlink — entita přechází do TRASHED (nebo je jí force unlink vynucen): processDeleting načte původní data z DB (moveItemDao.getOriginalData), zavolá moveAfterDeleting → unlink aktualizuje previousEntity u následníka, movePriority stlačí zbylé priority dolů, setActualEntityPriority(entityId, NO_PRIORITY) ukončí odpojení a publishChanges zapíše záznamy do journalu pro changedIds.
    • restore / force-link — entita se vrací z TRASHED: processRestoring přiřadí nejnižší (resp. nejvyšší) prioritu volného slotu, moveAfterRestoring zavolá link, které nastaví previousEntity na nové entitě i na novém následníkovi, a publikuje. Varianta processRestoringForCallback se používá v callback cestě (např. BaseVariantProductRelation při restore variantního produktu) a vynucuje provedení link inline — bez ní by za placeNewEntityToBeginning=true spojový seznam zůstal nezalinkovaný, protože callback restore neproudí přes afterStore.
    • nová entita — processCreating přiřadí prioritu (nejnižší−1 / nejvyšší+1) a předvyplní previousEntity přes moveItemDao.getLastEntityId. Skutečný link (vč. publikace) proběhne až v afterStore, kdy je známé id nové entity.
    • změna priority existující entity — updatePriority: unlink na původní pozici (publikuje), movePriority posune sousedy mezi starou a novou prioritou, nastaví novou prioritu, link na nové pozici (publikuje). Pokud se zároveň mění parentKeys (přesun mezi nadřazenými skupinami), provádí se obě fáze samostatně.

    Při disableDefaultPriorityManagement=true interceptor vrátí container okamžitě a žádná z výše uvedených operací neproběhne — priorita zůstane na hodnotě, kterou nastavil volající.

  2. intercept(MethodType.RemoveContainers, query) — beforeRemove (skutečné DELETE). Z WHERE klauzule dotazu extrahuje id mazaných entit a pro každé spustí processDeleting. Bez eq/in na id (nebo MovePriorityRemoveHint) vyhodí výjimku — hromadný DELETE bez znalosti konkrétních id by porušil spojový seznam.

  3. intercept(MetadataContainerProvider, …) — afterStore. Pouze pro placeNewEntityToBeginning=true: dokončí link nové entity (id už je k dispozici po insertu) a zapíše publikační záznamy. V default módu (placeNewEntityToBeginning=false) je linkování pro novou entitu hotové už v beforeStore přes processCreating a tato cesta neběží.

Spojový seznam — SQL operace

Tři DAO metody nad tabulkou entity:

  • unlink(parentKeys, entityId, originalPriority) — najde předchůdce (x2) a následníka (x3) entity, nastaví x3.previousEntity = x2.id, vrátí [x3.id] jako kandidáta na publikaci. Před updatem zavolá cleanActualEntityPreviousLink(entityId), který odpojí samotnou entitu — to obchází unique constraint na previousEntity během přemisťování.
  • link(parentKeys, entityId, priority) — symetrická operace: najde nového předchůdce a následníka, nastaví entity.previousEntity a x3.previousEntity = entityId, vrátí oba změněné id pro publikaci.
  • movePriority(parentKeys, newPriority, originalPriority) — hromadně posune priority ostatních entit mezi originalPriority a newPriority o ±1 tak, aby vznikl/zanikl slot. Provádí to jediným UPDATE; momentální kolize na páru (parent, priority) jsou ošetřené tím, že na tom páru není unique constraint (na rozdíl od previousEntity).
  • publishChanges(prioritySettings, changedIds, entityId) — pro každou změněnou entitu vloží do T_EDEESHOP_ENTITY_PUBLISHING_JOURNAL záznam typu prioritySettings.getPublishingType() (default CHANGE_PRIORITY). Sloupec data obsahuje id katalogové entity předchůdce (prev.idCatalogEntity) nebo NULL. Konzument (Evita publishing) záznamy zpracovává v pořadí podle id a aplikuje pouze poslední záznam pro daný (code, publishingType); mezilehlé záznamy jsou validní snapshoty průběhu, finální musí odpovídat DB.

ParameterItemMovePriorityPersistenceInterceptor

Speciální interceptor pro doménu parametrů, který dědí ze základního a registruje se navíc na view ParameterItem. Základní interceptor je pro ParameterItem explicitně vypnutý přes movePriorityPersistenceInterceptor.addDisabledFor(ParameterItem.ENTITY_NAME) — celý lifecycle parametrů řídí tato specializace.

Klíčové větve:

  • trash celého ParameterItem (processTrashOrRestoreBeforeStore) — iteruje všechny parameterInTypes (jak standardní, tak syntetickou stranu) a pro každý vyvolá processRemoved. Pro odpojené (priority == NO_PRIORITY) PITy je processRemoved no-op. Originální prioritu si bere z DB (moveItemDao.getOriginalData) — nikoli z in-memory entity, protože volající může předat zastaralou referenci.
  • restore celého ParameterItem — symetricky volá processRestoring pro každý PIT, který není absorbovaný (idParameterItemSynth == null).
  • změna idParameterItemSynth na PITu (processParameterItemRelationBeforeStore):
    • null → non-null = absorpce: processRemoved odpojí PIT ze spojového seznamu jeho parameterType.
    • non-null → null = uvolnění z absorpce: processRestoring zalinkuje PIT zpět na konec seznamu (nejnižší−1 priorita).

Druhá fáze kaskády při absorpci / uvolnění probíhá v after-persistence hooku ParameterItemEntityAfterPersistenceWithStorageFlagInterceptor. Ten po prvním uložení detekuje, že je nutné srovnat syntetickou stranu (shouldUpdateRelation):

  • Při absorpci, kdy už všechny standardPITy mají idParameterItemSynth a item má ještě syntetický PIT, odstraní syntetické PITy (removeSyntheticTypeRelations) a uloží znovu — ve druhém cyklu pak processParameterItemRelationBeforeStore zpracuje syntetický PIT v removedItems přes processRemoved a publikuje pro colorSynth view.
  • Při uvolnění, kdy item zůstal bez syntetické relace, přidá nový syntetický PIT (addNewParameterInType(synthParameterType)) a uloží znovu. processChangedOrNew v druhém cyklu nastaví prioritu a previousEntity, ale samotný link SQL ani publikaci nezavolá (pro placeNewEntityToBeginning=false). Proto interceptor po druhém ukládání explicitně publikuje přes publishNewSyntheticParameterInType. Tento publish se spouští jen když zároveň došlo ke změně existujícího PITu (modifiedItems != ∅), aby se neduplikoval u prvotního vytvoření ParameterItem, kde se synth PIT zakládá v rámci téže transakce a konzument o existenci entity ví z událostí o katalogové entitě.

Specifika doménových entit

Product / variant

  • Product se řadí v rámci katalogu (parentKeys = idCatalog). Varianty mají na úrovni Produktu priority = NO_PRIORITY (nejsou v katalogovém seznamu) a jsou zařazené ve spojovém seznamu VariantProductRelation v rámci masteru (parentKeys = idMasterProduct). Varianty publikují pod typem CHANGE_PRIORITY_VARIANT — CHANGE_PRIORITY filtr je nevidí.
  • Konverze revertVariantToBasicProduct / createProductVariant vyvolává přesun mezi dvěma spojovými seznamy: vyřazení z původního přes unlink a zařazení do nového přes link. Obě fáze publikují samostatně.
  • Ukončení prodeje (endProductSale) se chová jako trash pro účely seznamu (processDeleting s publishCurrentEntity=true), aby byla publikována i samotná entita.

ParameterItem / ParameterItemInType

  • Jedno tlačítko v UI „přiřaď pod syntetickou hodnotu" mění jeden sloupec idParameterItemSynth na standardním PITu, ale projevuje se ve dvou spojových seznamech (standardní v color, syntetický v colorSynth). Symetrie je zajištěna kaskádou popsanou výše — absorpce odstraňuje synth PIT, uvolnění ho zakládá zpět.
  • Při běhu testu generačního typu (ParameterItemInTypePriorityGenerationalTest) je dobré mít na paměti, že volání parameterService.removeParameterItem(item, …) čte prioritu PITů z in-memory reference. Volající proto musí předat čerstvě načtenou entitu — jinak zastaralá priorita vede k chybnému unlink ve špatném slotu spojového seznamu.

Přepínače chování

Obě hodnoty jsou na MovePriorityBehaviorSettings a lze je měnit za běhu (užívá toho i admin nastavení projektu i unit testy přes PriorityTestUtils.executeWithDisabledPriorityManagement / executeWithPlaceToBeginning).

disableDefaultPriorityManagement

  • Default false. Při true interceptor v intercept (beforeStore i beforeRemove) i v afterStore okamžitě vrací bez jediné SQL operace navíc — žádný unlink/link/ movePriority/publishChanges se nezavolá.
  • Důsledek: previousEntity zůstává NULL u všech entit, do publikačního journalu pro tuto entitu nepřijde žádný CHANGE_PRIORITY záznam, prioritu si musí řídit externí systém (typicky import z ERP).
  • Vypnout nelze tam, kde je registrované alwaysEnableDefaultPriorityManagement — to využívá např. ParameterItem a ParameterItemInType, kde syntetická kaskáda spolehlivě funguje jen s defaultním řízením.
  • V UI EdeeShopu jsou pro takto označenou entitu zakázané D&D operace v AR — bez previousEntity spojového seznamu nelze garantovat výsledek přesunu.

placeNewEntityToBeginning

  • Default false — nová entita dostane prioritu lowest − 1 a zařadí se na konec seznamu (nejnižší zobrazená priorita). Předchůdcem je dosavadní poslední položka.
  • Při true dostane nová entita prioritu highest + 1 a zařadí se na začátek (nejvyšší zobrazená priorita). Žádný předchůdce.
  • Linkování nové entity (nastavení previousEntity na ní i na následníkovi, publikace záznamů) proběhne až v afterStore — beforeStore v processCreating pouze předvyplní prioritu a previousEntity = null, protože id nové entity ještě není známé. Při restore přes processRestoringForCallback se naopak link vynucuje inline (callback neproudí přes afterStore).
  • Praktický dopad: nová entita je nahoře v seznamu a okolní entity se neposouvají (jen se nový head zařadí před ně) — to je důležité třeba pro produktové katalogy, kde má každá nová položka začínat jako nejviditelnější.

Datový tok publikace — shrnutí

kód
       ┌─────────────────────────────────────────┐       │ aplikační kód mění priority / link list │       └────────────────────┬────────────────────┘                            ▼   ┌────────────────────────────────────────────────────────┐   │ MovePriorityPersistenceInterceptor (beforeStore)       │   │  + ParameterItemMovePriorityPersistenceInterceptor     │   │     (jen pro doménu parametrů)                         │   └────────────────────┬───────────────────────────────────┘                        ▼   ┌────────────────────────────────────────────────────────┐   │ MoveItemJdbcTemplateDao                                │   │   unlink → movePriority → setActualEntityPriority      │   │     → link → publishChanges                            │   └────────────────────┬───────────────────────────────────┘                        ▼   ┌────────────────────────────────────────────────────────┐   │ T_EDEESHOP_ENTITY_PUBLISHING_JOURNAL                   │   │   type = CHANGE_PRIORITY[*]                            │   │   data = prev.idCatalogEntity | NULL                   │   └────────────────────┬───────────────────────────────────┘                        ▼   ┌────────────────────────────────────────────────────────┐   │ konzument (Evita publishing service)                   │   │   aplikuje POSLEDNÍ záznam na (code, publishingType)   │   └────────────────────────────────────────────────────────┘

Invariant ověřovaný generačními testy (ProductPriority*GenerationalTest, ParameterItemInTypePriorityGenerationalTest): pro každý (code, parameterType) musí poslední záznam v journalu odpovídat aktuálnímu stavu DB (previousEntity → previousCatalogEntity). Pokud ne, někde v kaskádě chybí publikace — přesně to byl případ uvolnění z absorpce u syntetických hodnot parametru, který opravil publishNewSyntheticParameterInType v ParameterItemEntityAfterPersistenceWithStorageFlagInterceptor.