From 638362df1c50c297f276e21995b4ec78ec00613c Mon Sep 17 00:00:00 2001 From: waruneeauy Date: Mon, 9 Feb 2026 12:35:59 +0700 Subject: [PATCH] feat: improve move-draft-to-current with differential sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement differential sync for organization structure and positions instead of delete-all-and-insert-all approach. Changes: - Add OrgIdMapping and AllOrgMappings interfaces for tracking ID mappings - Implement syncOrgLevel() helper for differential sync per org level - Add syncPositionsForPosMaster() helper for position table sync - Process org levels bottom-up (Child4→Child3→Child2→Child1→Root) - Use ancestorDNA matching with Like operator for descendant sync - Cascade delete positions before deleting org nodes - Batch DELETE/UPDATE/INSERT operations for better performance - Track draft→current ID mappings for position updates - Add comprehensive documentation in docs/move-draft-to-current.md Benefits: - Preserve IDs for unchanged nodes (better tracking) - More efficient (fewer database operations) - Better data integrity with proper FK handling - Sync all descendants under given rootDnaId Co-Authored-By: Claude Opus 4.6 --- docs/move-draft-to-current.md | 356 +++++++++++++ src/controllers/OrganizationController.ts | 582 +++++++++++++++++++++- src/interfaces/OrgMapping.ts | 24 + 3 files changed, 938 insertions(+), 24 deletions(-) create mode 100644 docs/move-draft-to-current.md create mode 100644 src/interfaces/OrgMapping.ts diff --git a/docs/move-draft-to-current.md b/docs/move-draft-to-current.md new file mode 100644 index 00000000..320c784e --- /dev/null +++ b/docs/move-draft-to-current.md @@ -0,0 +1,356 @@ +# Move Draft to Current - Differential Sync Implementation + +## Overview + +This document describes the implementation of the improved `move-draft-to-current` function in `OrganizationController.ts`. The function synchronizes organization structure and position data from the **Draft Revision** to the **Current Revision** using a differential sync approach (instead of the previous "delete all and insert all" method). + +**API Endpoint:** `POST /api/v1/org/move-draft-to-current/{rootDnaId}` + +--- + +## Architecture + +### Data Models + +The organization structure consists of 5 hierarchical levels: + +``` +OrgRoot (Level 0) + └── OrgChild1 (Level 1) + └── OrgChild2 (Level 2) + └── OrgChild3 (Level 3) + └── OrgChild4 (Level 4) +``` + +Each level has: +- Organization nodes with `ancestorDNA` for hierarchical tracking +- Foreign key relationships to parent levels +- Associated position records (`PosMaster`) + +### Type Definitions + +Located in `src/interfaces/OrgMapping.ts`: + +```typescript +interface OrgIdMapping { + byAncestorDNA: Map; // ancestorDNA → current ID + byDraftId: Map; // draft ID → current ID +} + +interface AllOrgMappings { + orgRoot: OrgIdMapping; + orgChild1: OrgIdMapping; + orgChild2: OrgIdMapping; + orgChild3: OrgIdMapping; + orgChild4: OrgIdMapping; +} +``` + +--- + +## Implementation Workflow + +### Phase 0: Preparation + +1. **Get Revision IDs** + - Fetch Draft Revision (`orgRevisionIsDraft: true`) + - Fetch Current Revision (`orgRevisionIsCurrent: true`) + +2. **Validate rootDnaId** + - Check if rootDnaId exists in Draft Revision + - Return error if not found + +### Phase 1: Sync Organization Structure (Bottom-Up) + +**Processing Order:** `OrgChild4 → OrgChild3 → OrgChild2 → OrgChild1 → OrgRoot` + +**Why Bottom-Up?** Child nodes have no dependent children (only parent references), allowing safe deletion without FK violations. + +#### For Each Organization Level + +The `syncOrgLevel()` helper performs: + +1. **FETCH** - Get all draft and current nodes under `rootDnaId` + ```typescript + where: { ancestorDNA: Like(`${rootDnaId}%`) } // All descendants + ``` + +2. **DELETE** - Remove current nodes not in draft + - Cascade delete positions first (via `cascadeDeletePositions()`) + - Delete the organization node + +3. **UPDATE** - Update nodes that exist in both (matched by `ancestorDNA`) + - Map parent IDs using `parentMappings` + - Preserve original node ID + +4. **INSERT** - Add draft nodes not in current + - Create new node with mapped parent IDs + - Return new ID for tracking + +5. **RETURN** - Return `OrgIdMapping` for next level + +**Result:** `allMappings` contains draft ID → current ID mappings for all org levels + +### Phase 2: Sync Position Data + +#### Step 2.1: Clear current_holderId + +```typescript +// Clear holders for positions that will have new holders +await queryRunner.manager.update(PosMaster, + { current_holderId: In(nextHolderIds) }, + { current_holderId: null, isSit: false } +) +``` + +#### Step 2.2: Fetch Draft and Current Positions + +- Get draft positions using `draftOrgIds` from `allMappings` +- Get current positions using `currentOrgIds` from `allMappings` + +#### Step 2.3: Batch DELETE + +```typescript +// Delete current positions not in draft (cascade delete positions first) +await queryRunner.manager.delete(Position, { posMasterId: In(toDeleteIds) }) +await queryRunner.manager.delete(PosMaster, toDeleteIds) +``` + +#### Step 2.4: Process UPDATE or INSERT + +For each draft position: +1. Map organization IDs using `resolveOrgId()` +2. If exists in current → **UPDATE** +3. If not exists → **INSERT** +4. Track `draftPosMasterId → currentPosMasterId` mapping + +#### Step 2.5: Sync Position Table + +For each mapped PosMaster: +```typescript +await syncPositionsForPosMaster( + queryRunner, + draftPosMasterId, + currentPosMasterId, + draftRevisionId, + currentRevisionId +) +``` + +--- + +## Helper Functions + +### `resolveOrgId(draftId, mapping)` + +Maps a draft organization ID to its current ID. + +```typescript +private resolveOrgId( + draftId: string | null, + mapping: OrgIdMapping +): string | null { + if (!draftId) return null; + return mapping.byDraftId.get(draftId) ?? null; +} +``` + +### `cascadeDeletePositions(queryRunner, node, entityClass)` + +Deletes positions associated with an organization node before deleting the node itself. + +```typescript +private async cascadeDeletePositions( + queryRunner: any, + node: any, + entityClass: any +): Promise { + const whereClause = { orgRevisionId: node.orgRevisionId }; + + // Set FK field based on entity type + if (entityClass === OrgRoot) whereClause.orgRootId = node.id; + else if (entityClass === OrgChild1) whereClause.orgChild1Id = node.id; + // ... etc + + await queryRunner.manager.delete(PosMaster, whereClause); +} +``` + +### `syncOrgLevel(...)` + +Generic differential sync for each organization level. + +**Parameters:** +- `queryRunner` - Database query runner +- `entityClass` - Organization entity class (OrgRoot, OrgChild1, etc.) +- `repository` - Repository for the entity +- `draftRevisionId` - Draft revision ID +- `currentRevisionId` - Current revision ID +- `rootDnaId` - Root DNA ID to sync under +- `parentMappings` - Mappings from child levels (for FK resolution) + +**Returns:** `OrgIdMapping` for this level + +### `syncPositionsForPosMaster(...)` + +Syncs positions for a PosMaster record. + +**Parameters:** +- `queryRunner` - Database query runner +- `draftPosMasterId` - Draft PosMaster ID +- `currentPosMasterId` - Current PosMaster ID +- `draftRevisionId` - Draft revision ID +- `currentRevisionId` - Current revision ID + +**Process:** +1. Fetch draft and current positions +2. Delete current positions not in draft (by `orderNo`) +3. Update existing positions +4. Insert new positions + +--- + +## Transaction Management + +All operations are wrapped in a transaction: + +```typescript +const queryRunner = AppDataSource.createQueryRunner(); +await queryRunner.connect(); +await queryRunner.startTransaction(); + +try { + // ... all sync operations + await queryRunner.commitTransaction(); +} catch (error) { + await queryRunner.rollbackTransaction(); + throw new HttpError(HttpStatusCode.INTERNAL_SERVER_ERROR, "..."); +} +``` + +--- + +## Benefits of Differential Sync + +| Aspect | Old Approach (Delete All + Insert) | New Approach (Differential Sync) | +|--------|-----------------------------------|----------------------------------| +| **ID Preservation** | All IDs changed | Unchanged nodes keep original IDs | +| **Performance** | N deletes + N inserts | Only changed data processed | +| **Tracking** | Cannot track what changed | Can track additions/updates/deletes | +| **Data Integrity** | Higher risk of data loss | Better integrity with cascade deletes | +| **Scalability** | Poor for large datasets | Efficient with batch operations | + +--- + +## Database Schema Relationships + +``` +orgRevision + ├── orgRoot (FK: orgRevisionId) + │ ├── orgChild1 (FK: orgRootId, orgRevisionId) + │ │ ├── orgChild2 (FK: orgChild1Id, orgRootId, orgRevisionId) + │ │ │ ├── orgChild3 (FK: orgChild2Id, orgChild1Id, orgRootId, orgRevisionId) + │ │ │ │ └── orgChild4 (FK: orgChild3Id, orgChild2Id, orgChild1Id, orgRootId, orgRevisionId) + │ │ │ │ + │ │ │ └── posMaster (FK: orgRootId, orgChild1Id, orgChild2Id, orgChild3Id, orgChild4Id) + │ │ │ └── position (FK: posMasterId) + │ │ │ + │ │ └── posMaster + │ │ + │ └── posMaster + │ + └── (current revision similar structure) +``` + +--- + +## Error Handling + +| Error Condition | Response | +|----------------|----------| +| Draft/Current revision not found | 404 NOT_FOUND | +| rootDnaId not found in draft | 404 NOT_FOUND | +| No positions in draft structure | 404 NOT_FOUND | +| Database error during sync | 500 INTERNAL_SERVER_ERROR (rollback) | + +--- + +## Files Modified/Created + +``` +src/ +├── interfaces/ +│ └── OrgMapping.ts [NEW] Type definitions +├── controllers/ +│ └── OrganizationController.ts [MODIFIED] moveDraftToCurrent function + helpers +docs/ +└── move-draft-to-current.md [NEW] This documentation +``` + +--- + +## Testing Considerations + +### Unit Tests + +- Test `syncOrgLevel()` for each org level with various scenarios +- Test `resolveOrgId()` mapping function +- Test `cascadeDeletePositions()` function +- Test `syncPositionsForPosMaster()` function + +### Integration Tests + +1. **Empty Draft** - Sync with no draft data +2. **Full Replacement** - All nodes changed +3. **Partial Update** - Some nodes added, some updated, some deleted +4. **Position Sync** - Verify position table syncs correctly +5. **Foreign Key Constraints** - Verify all FK relationships maintained + +### Manual Testing Flow + +1. Create draft structure with various changes: + - Add new department + - Modify existing department + - Remove existing department + - Add/modify/remove positions +2. Call `move-draft-to-current` API +3. Verify: + - New departments appear in current + - Modified departments are updated (not recreated) + - Removed departments are gone (with positions cascade deleted) + - Positions have correct new org IDs + - Position table records sync correctly + - All foreign key constraints satisfied + +--- + +## Performance Optimization + +- **Batch Operations** - Use `In()` clause for multiple IDs +- **Map Lookups** - Use `Map` for O(1) lookups instead of array searches +- **Bottom-Up Processing** - Minimize FK constraint checks +- **Parallel Queries** - Use `Promise.all()` for independent queries + +--- + +## Future Improvements + +1. **Parallel Processing** - Process independent org branches in parallel +2. **Incremental Sync** - Only sync changed subtrees +3. **Caching** - Cache org mappings for repeated operations +4. **Audit Log** - Track all changes for audit purposes +5. **Validation** - Add pre-sync validation to catch errors early + +--- + +## References + +- TypeORM Documentation: https://typeorm.io/ +- TSOA Documentation: https://tsoa-community.github.io/ +- Project Repository: [Internal Git] + +--- + +**Last Updated:** 2025-02-09 +**Author:** Claude Code +**Version:** 1.0.0 diff --git a/src/controllers/OrganizationController.ts b/src/controllers/OrganizationController.ts index 9cd6dd6a..1a919569 100644 --- a/src/controllers/OrganizationController.ts +++ b/src/controllers/OrganizationController.ts @@ -24,7 +24,7 @@ import HttpSuccess from "../interfaces/http-success"; import { OrgChild1 } from "../entities/OrgChild1"; import HttpError from "../interfaces/http-error"; import HttpStatusCode from "../interfaces/http-status"; -import { In, IsNull, Not } from "typeorm"; +import { In, IsNull, Not, Like } from "typeorm"; import { OrgRoot } from "../entities/OrgRoot"; import { OrgChild2 } from "../entities/OrgChild2"; import { OrgChild3 } from "../entities/OrgChild3"; @@ -57,6 +57,7 @@ import { CreatePosMasterHistoryOfficer, } from "../services/PositionService"; import { orgStructureCache } from "../utils/OrgStructureCache"; +import { OrgIdMapping, AllOrgMappings } from "../interfaces/OrgMapping"; @Route("api/v1/org") @Tags("Organization") @@ -7811,34 +7812,567 @@ export class OrganizationController extends Controller { * @summary - ย้ายโครงสร้างและตำแหน่งจากแบบร่างไปโครงสร้างปัจจุบัน โดยอ้างอิงตาม rootId * */ - @Post("move-draft-to-current/{rootId}") + @Post("move-draft-to-current/{rootDnaId}") async moveDraftToCurrent(@Request() request: RequestWithUser) { - // part 1 ข้อมูลโครงสร้าง - const drafRevision = await this.orgRevisionRepository.findOne({ - where: { - orgRevisionIsDraft: true, - orgRevisionIsCurrent: false, - }, - select: ["id"], - }); + const queryRunner = AppDataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); - const currentRevision = await this.orgRevisionRepository.findOne({ - where: { - orgRevisionIsDraft: false, - orgRevisionIsCurrent: true, - }, - select: ["id"], - }); + try { + // permission owner only ?? + // this code check... - if (!drafRevision || !currentRevision) - return new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบข้อมูล"); + // part 1 ข้อมูลโครงสร้าง + const [drafRevision, currentRevision] = await Promise.all([ + this.orgRevisionRepository.findOne({ + where: { + orgRevisionIsDraft: true, + orgRevisionIsCurrent: false, + }, + select: ["id"], + }), + this.orgRevisionRepository.findOne({ + where: { + orgRevisionIsDraft: false, + orgRevisionIsCurrent: true, + }, + select: ["id"], + }), + ]); - const drafRevisionId = drafRevision.id; - const currentRevisionId = currentRevision.id; + if (!drafRevision || !currentRevision) + return new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบข้อมูล"); - // start transaction multi table orgRoot, orgChild1, orgChild2, orgChild3, orgChild4, posMaster, position + const drafRevisionId = drafRevision.id; + const currentRevisionId = currentRevision.id; - // part 2 ข้อมูลตำแหน่ง - // 1. ดึงข้อมูลคนออกจากโครงสร้างปัจจุบัน + // ตรวจสอบว่ามี rootDnaId ในโครงสร้างร่าง และในโครงสร้างปัจจุบันหรือไม่ + const [orgRootDraft, orgRootCurrent] = await Promise.all([ + this.orgRootRepository.findOne({ + where: { + ancestorDNA: request.params.rootDnaId, + orgRevisionId: drafRevisionId, + }, + select: ["id"], + }), + this.orgRootRepository.findOne({ + where: { + ancestorDNA: request.params.rootDnaId, + orgRevisionId: currentRevisionId, + }, + select: ["id"], + }), + ]); + + if (!orgRootDraft) return new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบข้อมูลโครงสร้างร่าง"); + + // Part 1: Differential sync of organization structure (bottom-up) + // Build mapping incrementally as we process each level + const allMappings: AllOrgMappings = { + orgRoot: { byAncestorDNA: new Map(), byDraftId: new Map() }, + orgChild1: { byAncestorDNA: new Map(), byDraftId: new Map() }, + orgChild2: { byAncestorDNA: new Map(), byDraftId: new Map() }, + orgChild3: { byAncestorDNA: new Map(), byDraftId: new Map() }, + orgChild4: { byAncestorDNA: new Map(), byDraftId: new Map() } + }; + + // Process from bottom (Child4) to top (Root) to handle foreign key constraints + // Child4 (leaf nodes - no children depending on them) + allMappings.orgChild4 = await this.syncOrgLevel( + queryRunner, OrgChild4, this.child4Repository, + drafRevisionId, currentRevisionId, + request.params.rootDnaId, allMappings + ); + + // Child3 + allMappings.orgChild3 = await this.syncOrgLevel( + queryRunner, OrgChild3, this.child3Repository, + drafRevisionId, currentRevisionId, + request.params.rootDnaId, allMappings + ); + + // Child2 + allMappings.orgChild2 = await this.syncOrgLevel( + queryRunner, OrgChild2, this.child2Repository, + drafRevisionId, currentRevisionId, + request.params.rootDnaId, allMappings + ); + + // Child1 + allMappings.orgChild1 = await this.syncOrgLevel( + queryRunner, OrgChild1, this.child1Repository, + drafRevisionId, currentRevisionId, + request.params.rootDnaId, allMappings + ); + + // OrgRoot (root level - no parent mapping needed) + allMappings.orgRoot = await this.syncOrgLevel( + queryRunner, OrgRoot, this.orgRootRepository, + drafRevisionId, currentRevisionId, + request.params.rootDnaId + ); + + // Part 2: Sync position data using new org IDs from Part 1 + // 2.1 Clear current_holderId for affected positions (keep existing logic) + // Get draft organization IDs under the given rootDnaId to find positions to clear + const draftOrgIds = { + orgRoot: [...allMappings.orgRoot.byDraftId.keys()], + orgChild1: [...allMappings.orgChild1.byDraftId.keys()], + orgChild2: [...allMappings.orgChild2.byDraftId.keys()], + orgChild3: [...allMappings.orgChild3.byDraftId.keys()], + orgChild4: [...allMappings.orgChild4.byDraftId.keys()], + }; + + // Get draft positions that belong to any org under the rootDnaId + const posMasterDraft = await this.posMasterRepository.find({ + where: [ + { orgRevisionId: drafRevisionId, orgRootId: In(draftOrgIds.orgRoot) }, + { orgRevisionId: drafRevisionId, orgChild1Id: In(draftOrgIds.orgChild1) }, + { orgRevisionId: drafRevisionId, orgChild2Id: In(draftOrgIds.orgChild2) }, + { orgRevisionId: drafRevisionId, orgChild3Id: In(draftOrgIds.orgChild3) }, + { orgRevisionId: drafRevisionId, orgChild4Id: In(draftOrgIds.orgChild4) }, + ], + }); + + if (posMasterDraft.length <= 0) + return new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบข้อมูลตำแหน่งในโครงสร้างร่าง"); + + // Clear current_holderId for positions that will have new holders + const nextHolderIds = posMasterDraft + .filter(x => x.next_holderId != null) + .map(x => x.next_holderId); + + if (nextHolderIds.length > 0) { + await queryRunner.manager.update( + PosMaster, + { + orgRevisionId: currentRevisionId, + current_holderId: In(nextHolderIds) + }, + { current_holderId: null, isSit: false } + ); + } + + // 2.2 Fetch current positions for comparison + // Get current organization IDs from the mappings + const currentOrgIds = { + orgRoot: [...allMappings.orgRoot.byDraftId.values()], + orgChild1: [...allMappings.orgChild1.byDraftId.values()], + orgChild2: [...allMappings.orgChild2.byDraftId.values()], + orgChild3: [...allMappings.orgChild3.byDraftId.values()], + orgChild4: [...allMappings.orgChild4.byDraftId.values()], + }; + + const posMasterCurrent = await this.posMasterRepository.find({ + where: [ + { orgRevisionId: currentRevisionId, orgRootId: In(currentOrgIds.orgRoot) }, + { orgRevisionId: currentRevisionId, orgChild1Id: In(currentOrgIds.orgChild1) }, + { orgRevisionId: currentRevisionId, orgChild2Id: In(currentOrgIds.orgChild2) }, + { orgRevisionId: currentRevisionId, orgChild3Id: In(currentOrgIds.orgChild3) }, + { orgRevisionId: currentRevisionId, orgChild4Id: In(currentOrgIds.orgChild4) }, + ], + }); + + // Build lookup map + const currentByDNA = new Map( + posMasterCurrent.map(p => [p.ancestorDNA, p]) + ); + + // 2.3 Batch DELETE: positions in current but not in draft + const toDelete = posMasterCurrent.filter( + curr => !posMasterDraft.some(d => d.ancestorDNA === curr.ancestorDNA) + ); + + if (toDelete.length > 0) { + const toDeleteIds = toDelete.map(p => p.id); + + // Cascade delete positions first + await queryRunner.manager.delete( + Position, + { posMasterId: In(toDeleteIds) } + ); + + // Then delete posMaster records + await queryRunner.manager.delete( + PosMaster, + toDeleteIds + ); + } + + // 2.4 Process draft positions (UPDATE or INSERT) + const toUpdate: PosMaster[] = []; + const toInsert: any[] = []; + + // Track draft PosMaster ID to current PosMaster ID mapping for position sync + const posMasterMapping: Map = new Map(); + + for (const draftPos of posMasterDraft) { + const current = currentByDNA.get(draftPos.ancestorDNA); + + // Map organization IDs using new IDs from Part 1 + const orgRootId = this.resolveOrgId(draftPos.orgRootId ?? null, allMappings.orgRoot); + const orgChild1Id = this.resolveOrgId(draftPos.orgChild1Id ?? null, allMappings.orgChild1); + const orgChild2Id = this.resolveOrgId(draftPos.orgChild2Id ?? null, allMappings.orgChild2); + const orgChild3Id = this.resolveOrgId(draftPos.orgChild3Id ?? null, allMappings.orgChild3); + const orgChild4Id = this.resolveOrgId(draftPos.orgChild4Id ?? null, allMappings.orgChild4); + + if (current) { + // UPDATE existing position + Object.assign(current, { + createdAt: draftPos.createdAt, + createdUserId: draftPos.createdUserId, + createdFullName: draftPos.createdFullName, + lastUpdatedAt: new Date(), + lastUpdateUserId: request.user.sub, + lastUpdateFullName: request.user.name, + posMasterNoPrefix: draftPos.posMasterNoPrefix, + posMasterNoSuffix: draftPos.posMasterNoSuffix, + posMasterNo: draftPos.posMasterNo, + posMasterOrder: draftPos.posMasterOrder, + orgRootId, + orgChild1Id, + orgChild2Id, + orgChild3Id, + orgChild4Id, + current_holderId: draftPos.next_holderId, + isSit: draftPos.isSit, + reason: draftPos.reason, + isDirector: draftPos.isDirector, + isStaff: draftPos.isStaff, + positionSign: draftPos.positionSign, + statusReport: "DONE", + isCondition: draftPos.isCondition, + conditionReason: draftPos.conditionReason, + }); + toUpdate.push(current); + + // Track mapping for position sync + posMasterMapping.set(draftPos.id, current.id); + } else { + // INSERT new position + const newPosMaster = queryRunner.manager.create(PosMaster, { + ...draftPos, + id: undefined, + orgRevisionId: currentRevisionId, + orgRootId, + orgChild1Id, + orgChild2Id, + orgChild3Id, + orgChild4Id, + current_holderId: draftPos.next_holderId, + statusReport: "DONE", + }); + toInsert.push(newPosMaster); + } + } + + // Batch save updates and inserts + if (toUpdate.length > 0) { + await queryRunner.manager.save(toUpdate); + } + if (toInsert.length > 0) { + const saved = await queryRunner.manager.save(toInsert); + + // Track mapping for newly inserted posMasters + // saved is an array, map each to its draft ID + if (Array.isArray(saved)) { + for (let i = 0; i < saved.length; i++) { + const draftPos = posMasterDraft.filter(d => !currentByDNA.has(d.ancestorDNA))[i]; + if (draftPos && saved[i]) { + posMasterMapping.set(draftPos.id, saved[i].id); + } + } + } + } + + // 2.5 Sync positions table for all affected posMasters + for (const [draftPosMasterId, currentPosMasterId] of posMasterMapping) { + await this.syncPositionsForPosMaster( + queryRunner, + draftPosMasterId, + currentPosMasterId, + drafRevisionId, + currentRevisionId + ); + } + + await queryRunner.commitTransaction(); + } catch (error) { + console.error("Error moving draft to current:", error); + await queryRunner.rollbackTransaction(); + throw new HttpError(HttpStatusCode.INTERNAL_SERVER_ERROR, "เกิดข้อผิดพลาดในการย้ายโครงสร้าง"); + } + } + + /** + * Helper function: Map draft ID to current ID using the mapping + */ + private resolveOrgId( + draftId: string | null, + mapping: OrgIdMapping + ): string | null { + if (!draftId) return null; + return mapping.byDraftId.get(draftId) ?? null; + } + + /** + * Helper function: Cascade delete positions before deleting org node + */ + private async cascadeDeletePositions( + queryRunner: any, + node: any, + entityClass: any + ): Promise { + const whereClause: any = { + orgRevisionId: node.orgRevisionId + }; + + // Determine which FK field to use based on entity type + if (entityClass === OrgRoot) { + whereClause.orgRootId = node.id; + } else if (entityClass === OrgChild1) { + whereClause.orgChild1Id = node.id; + } else if (entityClass === OrgChild2) { + whereClause.orgChild2Id = node.id; + } else if (entityClass === OrgChild3) { + whereClause.orgChild3Id = node.id; + } else if (entityClass === OrgChild4) { + whereClause.orgChild4Id = node.id; + } + + await queryRunner.manager.delete(PosMaster, whereClause); + } + + /** + * Helper function: Generic differential sync for each org level + * Performs DELETE (nodes not in draft), UPDATE (existing nodes), INSERT (new nodes) + */ + private async syncOrgLevel( + queryRunner: any, + entityClass: any, + repository: any, + draftRevisionId: string, + currentRevisionId: string, + rootDnaId: string, + parentMappings?: AllOrgMappings + ): Promise { + // 1. Fetch draft and current nodes under the given rootDnaId + const [draftNodes, currentNodes] = await Promise.all([ + repository.find({ + where: { + orgRevisionId: draftRevisionId, + ancestorDNA: Like(`${rootDnaId}%`) + } + }), + repository.find({ + where: { + orgRevisionId: currentRevisionId, + ancestorDNA: Like(`${rootDnaId}%`) + } + }) + ]); + + // 2. Build lookup maps for efficient matching by ancestorDNA + const draftByDNA = new Map(draftNodes.map((n: any) => [n.ancestorDNA, n])); + const currentByDNA = new Map(currentNodes.map((n: any) => [n.ancestorDNA, n])); + + const mapping: OrgIdMapping = { + byAncestorDNA: new Map(), + byDraftId: new Map() + }; + + // 3. DELETE: Current nodes not in draft (cascade delete positions first) + const toDelete = currentNodes.filter((curr: any) => !draftByDNA.has(curr.ancestorDNA)); + for (const node of toDelete) { + // Cascade delete positions first + await this.cascadeDeletePositions(queryRunner, node, entityClass); + await queryRunner.manager.delete(entityClass, node.id); + } + + // 4. UPDATE: Nodes that exist in both draft and current (matched by ancestorDNA) + const toUpdate = draftNodes.filter((draft: any) => currentByDNA.has(draft.ancestorDNA)); + for (const draft of toUpdate) { + const current: any = currentByDNA.get(draft.ancestorDNA)!; + + // Build update data with mapped parent IDs + const updateData: any = { + ...draft, + id: current.id, + orgRevisionId: currentRevisionId, + }; + + // Map parent IDs based on entity level + if (entityClass === OrgChild1 && draft.orgRootId && parentMappings) { + updateData.orgRootId = parentMappings.orgRoot.byDraftId.get(draft.orgRootId) ?? draft.orgRootId; + } else if (entityClass === OrgChild2) { + if (draft.orgRootId && parentMappings) { + updateData.orgRootId = parentMappings.orgRoot.byDraftId.get(draft.orgRootId) ?? draft.orgRootId; + } + if (draft.orgChild1Id && parentMappings) { + updateData.orgChild1Id = parentMappings.orgChild1.byDraftId.get(draft.orgChild1Id) ?? draft.orgChild1Id; + } + } else if (entityClass === OrgChild3) { + if (draft.orgRootId && parentMappings) { + updateData.orgRootId = parentMappings.orgRoot.byDraftId.get(draft.orgRootId) ?? draft.orgRootId; + } + if (draft.orgChild1Id && parentMappings) { + updateData.orgChild1Id = parentMappings.orgChild1.byDraftId.get(draft.orgChild1Id) ?? draft.orgChild1Id; + } + if (draft.orgChild2Id && parentMappings) { + updateData.orgChild2Id = parentMappings.orgChild2.byDraftId.get(draft.orgChild2Id) ?? draft.orgChild2Id; + } + } else if (entityClass === OrgChild4) { + if (draft.orgRootId && parentMappings) { + updateData.orgRootId = parentMappings.orgRoot.byDraftId.get(draft.orgRootId) ?? draft.orgRootId; + } + if (draft.orgChild1Id && parentMappings) { + updateData.orgChild1Id = parentMappings.orgChild1.byDraftId.get(draft.orgChild1Id) ?? draft.orgChild1Id; + } + if (draft.orgChild2Id && parentMappings) { + updateData.orgChild2Id = parentMappings.orgChild2.byDraftId.get(draft.orgChild2Id) ?? draft.orgChild2Id; + } + if (draft.orgChild3Id && parentMappings) { + updateData.orgChild3Id = parentMappings.orgChild3.byDraftId.get(draft.orgChild3Id) ?? draft.orgChild3Id; + } + } + + await queryRunner.manager.update(entityClass, current.id, updateData); + + mapping.byAncestorDNA.set(draft.ancestorDNA, current.id); + mapping.byDraftId.set(draft.id, current.id); + } + + // 5. INSERT: Draft nodes not in current + const toInsert = draftNodes.filter((draft: any) => !currentByDNA.has(draft.ancestorDNA)); + for (const draft of toInsert) { + const newNode: any = queryRunner.manager.create(entityClass, { + ...draft, + id: undefined, + orgRevisionId: currentRevisionId, + }); + + // Map parent IDs based on entity level + if (entityClass === OrgChild1 && draft.orgRootId && parentMappings) { + newNode.orgRootId = parentMappings.orgRoot.byDraftId.get(draft.orgRootId); + } else if (entityClass === OrgChild2) { + if (draft.orgRootId && parentMappings) { + newNode.orgRootId = parentMappings.orgRoot.byDraftId.get(draft.orgRootId); + } + if (draft.orgChild1Id && parentMappings) { + newNode.orgChild1Id = parentMappings.orgChild1.byDraftId.get(draft.orgChild1Id); + } + } else if (entityClass === OrgChild3) { + if (draft.orgRootId && parentMappings) { + newNode.orgRootId = parentMappings.orgRoot.byDraftId.get(draft.orgRootId); + } + if (draft.orgChild1Id && parentMappings) { + newNode.orgChild1Id = parentMappings.orgChild1.byDraftId.get(draft.orgChild1Id); + } + if (draft.orgChild2Id && parentMappings) { + newNode.orgChild2Id = parentMappings.orgChild2.byDraftId.get(draft.orgChild2Id); + } + } else if (entityClass === OrgChild4) { + if (draft.orgRootId && parentMappings) { + newNode.orgRootId = parentMappings.orgRoot.byDraftId.get(draft.orgRootId); + } + if (draft.orgChild1Id && parentMappings) { + newNode.orgChild1Id = parentMappings.orgChild1.byDraftId.get(draft.orgChild1Id); + } + if (draft.orgChild2Id && parentMappings) { + newNode.orgChild2Id = parentMappings.orgChild2.byDraftId.get(draft.orgChild2Id); + } + if (draft.orgChild3Id && parentMappings) { + newNode.orgChild3Id = parentMappings.orgChild3.byDraftId.get(draft.orgChild3Id); + } + } + + const saved = await queryRunner.manager.save(newNode); + + mapping.byAncestorDNA.set(draft.ancestorDNA, saved.id); + mapping.byDraftId.set(draft.id, saved.id); + } + + return mapping; + } + + /** + * Helper function: Sync positions for a PosMaster + * Handles DELETE/UPDATE/INSERT for positions associated with a posMaster + */ + private async syncPositionsForPosMaster( + queryRunner: any, + draftPosMasterId: string, + currentPosMasterId: string, + draftRevisionId: string, + currentRevisionId: string + ): Promise { + // Fetch draft and current positions for this posMaster + const [draftPositions, currentPositions] = await Promise.all([ + queryRunner.manager.find(Position, { + where: { + posMasterId: draftPosMasterId + }, + order: { orderNo: 'ASC' } + }), + queryRunner.manager.find(Position, { + where: { + posMasterId: currentPosMasterId + } + }) + ]); + + // If no draft positions, delete all current positions + if (draftPositions.length === 0) { + if (currentPositions.length > 0) { + await queryRunner.manager.delete( + Position, + currentPositions.map((p: any) => p.id) + ); + } + return; + } + + // Build maps for tracking + const currentByOrderNo = new Map( + currentPositions.map((p: any) => [p.orderNo, p]) + ); + + // DELETE: Current positions not in draft (by orderNo) + const draftOrderNos = new Set(draftPositions.map((p: any) => p.orderNo)); + const toDelete = currentPositions.filter((p: any) => !draftOrderNos.has(p.orderNo)); + + if (toDelete.length > 0) { + await queryRunner.manager.delete( + Position, + toDelete.map((p: any) => p.id) + ); + } + + // UPDATE and INSERT + for (const draftPos of draftPositions) { + const current: any = currentByOrderNo.get(draftPos.orderNo); + + if (current) { + // UPDATE existing position + await queryRunner.manager.update(Position, current.id, { + positionName: draftPos.positionName, + positionField: draftPos.positionField, + posTypeId: draftPos.posTypeId, + posLevelId: draftPos.posLevelId, + posExecutiveId: draftPos.posExecutiveId, + positionExecutiveField: draftPos.positionExecutiveField, + positionArea: draftPos.positionArea, + isSpecial: draftPos.isSpecial, + }); + } else { + // INSERT new position + const newPosition = queryRunner.manager.create(Position, { + ...draftPos, + id: undefined, + posMasterId: currentPosMasterId, + }); + await queryRunner.manager.save(newPosition); + } + } } } diff --git a/src/interfaces/OrgMapping.ts b/src/interfaces/OrgMapping.ts new file mode 100644 index 00000000..1cdea98f --- /dev/null +++ b/src/interfaces/OrgMapping.ts @@ -0,0 +1,24 @@ +/** + * Type definitions for organization mapping used in move-draft-to-current function + */ + +/** + * Maps draft organization IDs to current organization IDs + * - byAncestorDNA: Maps ancestorDNA to current ID for lookup + * - byDraftId: Maps draft ID to current ID for position updates + */ +export interface OrgIdMapping { + byAncestorDNA: Map; + byDraftId: Map; +} + +/** + * Contains mappings for all organization levels + */ +export interface AllOrgMappings { + orgRoot: OrgIdMapping; + orgChild1: OrgIdMapping; + orgChild2: OrgIdMapping; + orgChild3: OrgIdMapping; + orgChild4: OrgIdMapping; +}