feat: improve move-draft-to-current with differential sync

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 <noreply@anthropic.com>
This commit is contained in:
Warunee Tamkoo 2026-02-09 12:35:59 +07:00
parent 22fc43fe17
commit 638362df1c
3 changed files with 938 additions and 24 deletions

View file

@ -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<string, string> = 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<void> {
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<T extends OrgRoot | OrgChild1 | OrgChild2 | OrgChild3 | OrgChild4>(
queryRunner: any,
entityClass: any,
repository: any,
draftRevisionId: string,
currentRevisionId: string,
rootDnaId: string,
parentMappings?: AllOrgMappings
): Promise<OrgIdMapping> {
// 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<void> {
// 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);
}
}
}
}