diff --git a/plugins/export-resources/src/export.ts b/plugins/export-resources/src/export.ts index 3a3830db64b..b47473c25ed 100644 --- a/plugins/export-resources/src/export.ts +++ b/plugins/export-resources/src/export.ts @@ -52,8 +52,8 @@ export async function exportToWorkspace ( minor: 0, reviewers: [], controlledState: '', - seqNumber: '$ensureUnique', - code: '$ensureUnique' + seqNumber: '$generateSeqNumber', + code: '$generateCode' }, 'documents:class:DocumentMeta': { author: '$currentUser', diff --git a/services/export/pod-export/src/__tests__/data-mapper.test.ts b/services/export/pod-export/src/__tests__/data-mapper.test.ts index 27308baabe8..b3ce874c1ad 100644 --- a/services/export/pod-export/src/__tests__/data-mapper.test.ts +++ b/services/export/pod-export/src/__tests__/data-mapper.test.ts @@ -143,7 +143,7 @@ function createMockMeasureContext (): MeasureContext { } as unknown as MeasureContext } -describe('DataMapper - ensureFieldUnique', () => { +describe('DataMapper - generateSeqNumber', () => { let mockContext: MeasureContext let mockClient: TxOperations let state: ExportState @@ -159,542 +159,502 @@ describe('DataMapper - ensureFieldUnique', () => { jest.clearAllMocks() }) - describe('String values with prefix pattern', () => { - it('should find unique value by querying all prefix values at once', async () => { - // Setup: existing docs with codes DOC-1, DOC-2, DOC-5, all with same prefix - const testPrefix = 'DOC' - const existingDocs = [ - { - _id: generateId(), - _class: mockDocClass, - code: 'DOC-1', - prefix: testPrefix, - space: mockSpaceId, - modifiedOn: platformNow(), - modifiedBy: generateId() - }, - { - _id: generateId(), - _class: mockDocClass, - code: 'DOC-2', - prefix: testPrefix, - space: mockSpaceId, - modifiedOn: platformNow(), - modifiedBy: generateId() - }, - { - _id: generateId(), - _class: mockDocClass, - code: 'DOC-5', - prefix: testPrefix, - space: mockSpaceId, - modifiedOn: platformNow(), - modifiedBy: generateId() + it('should generate seqNumber 1 when no existing documents', async () => { + const testPrefix = 'DOC' + mockClient = createMockTxOperations([]) + + const dataMapper = new DataMapper( + mockContext, + mockClient, + state, + { + [mockDocClass]: { + seqNumber: '$generateSeqNumber' } - ] - mockClient = createMockTxOperations(existingDocs) - - const dataMapper = new DataMapper( - mockContext, - mockClient, - state, - { - [mockDocClass]: { - code: '$ensureUnique' - } - }, - undefined - ) + }, + undefined + ) + + const doc = { + _id: generateId(), + _class: mockDocClass, + prefix: testPrefix, + space: mockSpaceId, + modifiedOn: platformNow(), + modifiedBy: generateId() as any + } - const doc = { - _id: generateId(), - _class: mockDocClass, - code: 'DOC-1', - prefix: testPrefix, - space: mockSpaceId, - modifiedOn: platformNow(), - modifiedBy: generateId() as any - } + const result = await dataMapper.prepareDocumentData(doc, mockSpaceId, false) - const result = await dataMapper.prepareDocumentData(doc, mockSpaceId, false) - - // DOC-1 conflicts with existing, should use max(1,2,5) + 1 = 6 - expect(result.code).toBe('DOC-6') - // Should query by prefix field (global, no space filter) - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockClient.findAll).toHaveBeenCalledWith( - mockDocClass, - { prefix: testPrefix }, - { projection: { code: 1, prefix: 1 } } - ) - }) - - it('should handle prefix with special characters', async () => { - const testPrefix = 'DOC-TEST' - const existingDocs = [ - { - _id: generateId(), - _class: mockDocClass, - code: 'DOC-TEST-1', - prefix: testPrefix, - space: mockSpaceId, - modifiedOn: platformNow(), - modifiedBy: generateId() - }, - { - _id: generateId(), - _class: mockDocClass, - code: 'DOC-TEST-2', - prefix: testPrefix, - space: mockSpaceId, - modifiedOn: platformNow(), - modifiedBy: generateId() - } - ] - mockClient = createMockTxOperations(existingDocs) - - const dataMapper = new DataMapper( - mockContext, - mockClient, - state, - { - [mockDocClass]: { - code: '$ensureUnique' - } - }, - undefined - ) + expect(result.seqNumber).toBe(1) + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockClient.findAll).toHaveBeenCalledWith( + mockDocClass, + { prefix: testPrefix }, + { projection: { seqNumber: 1, prefix: 1 } } + ) + }) - const doc = { + it('should generate seqNumber as max + 1 when existing documents exist', async () => { + const testPrefix = 'DOC' + const existingDocs = [ + { _id: generateId(), _class: mockDocClass, - code: 'DOC-TEST-1', + seqNumber: 5, prefix: testPrefix, space: mockSpaceId, modifiedOn: platformNow(), - modifiedBy: generateId() as any - } - - const result = await dataMapper.prepareDocumentData(doc, mockSpaceId, false) - - // Should use max(1,2) + 1 = 3 - expect(result.code).toBe('DOC-TEST-3') - }) - - it('should handle values already used in export batch', async () => { - const testPrefix = 'DOC' - const existingDocs: any[] = [] - mockClient = createMockTxOperations(existingDocs) - - const dataMapper = new DataMapper( - mockContext, - mockClient, - state, - { - [mockDocClass]: { - code: '$ensureUnique' - } - }, - undefined - ) - - // First document - const doc1 = { + modifiedBy: generateId() + }, + { _id: generateId(), _class: mockDocClass, - code: 'DOC-1', + seqNumber: 10, prefix: testPrefix, space: mockSpaceId, modifiedOn: platformNow(), - modifiedBy: generateId() as any - } - const result1 = await dataMapper.prepareDocumentData(doc1, mockSpaceId, false) - expect(result1.code).toBe('DOC-1') - - // Second document with same code - const doc2 = { + modifiedBy: generateId() + }, + { _id: generateId(), _class: mockDocClass, - code: 'DOC-1', + seqNumber: 3, prefix: testPrefix, space: mockSpaceId, modifiedOn: platformNow(), - modifiedBy: generateId() as any + modifiedBy: generateId() } - const result2 = await dataMapper.prepareDocumentData(doc2, mockSpaceId, false) - - // Should increment to DOC-2 since DOC-1 is already used in batch - expect(result2.code).toBe('DOC-2') - }) - - it('should handle string without prefix pattern', async () => { - const existingDocs = [ - { - _id: generateId(), - _class: mockDocClass, - code: 'SIMPLE', - space: mockSpaceId, - modifiedOn: platformNow(), - modifiedBy: generateId() + ] + mockClient = createMockTxOperations(existingDocs) + + const dataMapper = new DataMapper( + mockContext, + mockClient, + state, + { + [mockDocClass]: { + seqNumber: '$generateSeqNumber' } - ] - mockClient = createMockTxOperations(existingDocs) - - const dataMapper = new DataMapper( - mockContext, - mockClient, - state, - { - [mockDocClass]: { - code: '$ensureUnique' - } - }, - undefined - ) - - const doc = { - _id: generateId(), - _class: mockDocClass, - code: 'SIMPLE', - space: mockSpaceId, - modifiedOn: platformNow(), - modifiedBy: generateId() as any - } + }, + undefined + ) + + const doc = { + _id: generateId(), + _class: mockDocClass, + prefix: testPrefix, + space: mockSpaceId, + modifiedOn: platformNow(), + modifiedBy: generateId() as any + } - const result = await dataMapper.prepareDocumentData(doc, mockSpaceId, false) + const result = await dataMapper.prepareDocumentData(doc, mockSpaceId, false) - // Should append suffix - expect(result.code).toBe('SIMPLE-1') - // Should use findOne for exact match (global, no space filter) - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockClient.findOne).toHaveBeenCalledWith(mockDocClass, { code: 'SIMPLE' }, { projection: { code: 1 } }) - }) + // Should use max(5, 10, 3) + 1 = 11 + expect(result.seqNumber).toBe(11) }) - describe('Numeric values', () => { - it('should find unique value by querying all values >= current', async () => { - // Setup: existing docs with seqNumber 10, 11, 15 - const existingDocs = [ - { - _id: generateId(), - _class: mockDocClass, - seqNumber: 10, - space: mockSpaceId, - modifiedOn: platformNow(), - modifiedBy: generateId() - }, - { - _id: generateId(), - _class: mockDocClass, - seqNumber: 11, - space: mockSpaceId, - modifiedOn: platformNow(), - modifiedBy: generateId() - }, - { - _id: generateId(), - _class: mockDocClass, - seqNumber: 15, - space: mockSpaceId, - modifiedOn: platformNow(), - modifiedBy: generateId() + it('should track seqNumbers used in export batch', async () => { + const testPrefix = 'DOC' + mockClient = createMockTxOperations([]) + + const dataMapper = new DataMapper( + mockContext, + mockClient, + state, + { + [mockDocClass]: { + seqNumber: '$generateSeqNumber' } - ] - mockClient = createMockTxOperations(existingDocs) - - const dataMapper = new DataMapper( - mockContext, - mockClient, - state, - { - [mockDocClass]: { - seqNumber: '$ensureUnique' - } - }, - undefined - ) + }, + undefined + ) + + // First document + const doc1 = { + _id: generateId(), + _class: mockDocClass, + prefix: testPrefix, + space: mockSpaceId, + modifiedOn: platformNow(), + modifiedBy: generateId() as any + } + const result1 = await dataMapper.prepareDocumentData(doc1, mockSpaceId, false) + expect(result1.seqNumber).toBe(1) + + // Second document should get seqNumber 2 + const doc2 = { + _id: generateId(), + _class: mockDocClass, + prefix: testPrefix, + space: mockSpaceId, + modifiedOn: platformNow(), + modifiedBy: generateId() as any + } + const result2 = await dataMapper.prepareDocumentData(doc2, mockSpaceId, false) + expect(result2.seqNumber).toBe(2) + }) - const doc = { + it('should handle documents with different prefixes separately', async () => { + const existingDocs = [ + { _id: generateId(), _class: mockDocClass, seqNumber: 10, + prefix: 'DOC-A', space: mockSpaceId, modifiedOn: platformNow(), - modifiedBy: generateId() as any - } - - const result = await dataMapper.prepareDocumentData(doc, mockSpaceId, false) - - // Should use max(10,11,15) + 1 = 16 - expect(result.seqNumber).toBe(16) - // Should query with $gte (global, no space filter) - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockClient.findAll).toHaveBeenCalledWith( - mockDocClass, - { seqNumber: { $gte: 10 } }, - { projection: { seqNumber: 1 } } - ) - }) - - it('should handle numeric values already used in export batch', async () => { - const existingDocs: any[] = [] - mockClient = createMockTxOperations(existingDocs) - - const dataMapper = new DataMapper( - mockContext, - mockClient, - state, - { - [mockDocClass]: { - seqNumber: '$ensureUnique' - } - }, - undefined - ) - - // First document - const doc1 = { + modifiedBy: generateId() + }, + { _id: generateId(), _class: mockDocClass, seqNumber: 5, + prefix: 'DOC-B', space: mockSpaceId, modifiedOn: platformNow(), - modifiedBy: generateId() as any + modifiedBy: generateId() } - const result1 = await dataMapper.prepareDocumentData(doc1, mockSpaceId, false) - expect(result1.seqNumber).toBe(5) + ] + mockClient = createMockTxOperations(existingDocs) + + const dataMapper = new DataMapper( + mockContext, + mockClient, + state, + { + [mockDocClass]: { + seqNumber: '$generateSeqNumber' + } + }, + undefined + ) + + // Document with DOC-A prefix + const docA = { + _id: generateId(), + _class: mockDocClass, + prefix: 'DOC-A', + space: mockSpaceId, + modifiedOn: platformNow(), + modifiedBy: generateId() as any + } + const resultA = await dataMapper.prepareDocumentData(docA, mockSpaceId, false) + expect(resultA.seqNumber).toBe(11) // max(10) + 1 + + // Document with DOC-B prefix + const docB = { + _id: generateId(), + _class: mockDocClass, + prefix: 'DOC-B', + space: mockSpaceId, + modifiedOn: platformNow(), + modifiedBy: generateId() as any + } + const resultB = await dataMapper.prepareDocumentData(docB, mockSpaceId, false) + expect(resultB.seqNumber).toBe(6) // max(5) + 1 + }) - // Second document with same seqNumber - const doc2 = { - _id: generateId(), - _class: mockDocClass, - seqNumber: 5, - space: mockSpaceId, - modifiedOn: platformNow(), - modifiedBy: generateId() as any - } - const result2 = await dataMapper.prepareDocumentData(doc2, mockSpaceId, false) - - // Should increment to 6 since 5 is already used in batch - expect(result2.seqNumber).toBe(6) - }) - - it('should handle numeric value when no conflicts exist', async () => { - const existingDocs: any[] = [] - mockClient = createMockTxOperations(existingDocs) - - const dataMapper = new DataMapper( - mockContext, - mockClient, - state, - { - [mockDocClass]: { - seqNumber: '$ensureUnique' - } - }, - undefined - ) + it('should skip generation if prefix is missing', async () => { + mockClient = createMockTxOperations([]) - const doc = { - _id: generateId(), - _class: mockDocClass, - seqNumber: 1, - space: mockSpaceId, - modifiedOn: platformNow(), - modifiedBy: generateId() as any - } + const dataMapper = new DataMapper( + mockContext, + mockClient, + state, + { + [mockDocClass]: { + seqNumber: '$generateSeqNumber' + } + }, + undefined + ) + + const doc = { + _id: generateId(), + _class: mockDocClass, + space: mockSpaceId, + modifiedOn: platformNow(), + modifiedBy: generateId() as any + } - const result = await dataMapper.prepareDocumentData(doc, mockSpaceId, false) + const result = await dataMapper.prepareDocumentData(doc, mockSpaceId, false) - // Should keep original value if unique - expect(result.seqNumber).toBe(1) - }) + expect(result.seqNumber).toBeUndefined() + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockContext.warn).toHaveBeenCalledWith( + 'generateSeqNumber: prefix is required but not found, skipping seqNumber generation' + ) }) - describe('Projection usage', () => { - it('should use projection to only load the field being checked', async () => { - const existingDocs = [ - { - _id: generateId(), - _class: mockDocClass, - code: 'DOC-1', - title: 'Title 1', - space: mockSpaceId, - modifiedOn: platformNow(), - modifiedBy: generateId() - } - ] - mockClient = createMockTxOperations(existingDocs) - - const dataMapper = new DataMapper( - mockContext, - mockClient, - state, - { - [mockDocClass]: { - code: '$ensureUnique' - } - }, - undefined - ) + it('should skip generation if prefix is empty string', async () => { + mockClient = createMockTxOperations([]) - const doc = { - _id: generateId(), - _class: mockDocClass, - code: 'DOC-2', - title: 'Title 2', - space: mockSpaceId, - modifiedOn: platformNow(), - modifiedBy: generateId() as any - } + const dataMapper = new DataMapper( + mockContext, + mockClient, + state, + { + [mockDocClass]: { + seqNumber: '$generateSeqNumber' + } + }, + undefined + ) + + const doc = { + _id: generateId(), + _class: mockDocClass, + prefix: '', + space: mockSpaceId, + modifiedOn: platformNow(), + modifiedBy: generateId() as any + } - await dataMapper.prepareDocumentData(doc, mockSpaceId, false) + const result = await dataMapper.prepareDocumentData(doc, mockSpaceId, false) - // Verify projection was used - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockClient.findAll).toHaveBeenCalledWith(mockDocClass, expect.anything(), { projection: { code: 1 } }) - }) + expect(result.seqNumber).toBeUndefined() + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockContext.warn).toHaveBeenCalledWith( + 'generateSeqNumber: prefix is required but not found, skipping seqNumber generation' + ) }) +}) - describe('Edge cases', () => { - it('should handle null/undefined values gracefully', async () => { - mockClient = createMockTxOperations([]) - - const dataMapper = new DataMapper( - mockContext, - mockClient, - state, - { - [mockDocClass]: { - code: '$ensureUnique' - } - }, - undefined - ) - - const doc = { - _id: generateId(), - _class: mockDocClass, - code: null, - space: mockSpaceId, - modifiedOn: platformNow(), - modifiedBy: generateId() as any - } +describe('DataMapper - generateCode', () => { + let mockContext: MeasureContext + let mockClient: TxOperations + let state: ExportState - const result = await dataMapper.prepareDocumentData(doc, mockSpaceId, false) + beforeEach(() => { + mockContext = createMockMeasureContext() + state = { + idMapping: new Map(), + spaceMapping: new Map(), + processingDocs: new Set(), + uniqueFieldValues: new Map() + } + jest.clearAllMocks() + }) - // Should not modify null values - expect(result.code).toBeNull() - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockClient.findAll).not.toHaveBeenCalled() - }) + it('should generate code from prefix and seqNumber', async () => { + const testPrefix = 'DOC' + mockClient = createMockTxOperations([]) + + const dataMapper = new DataMapper( + mockContext, + mockClient, + state, + { + [mockDocClass]: { + seqNumber: '$generateSeqNumber', + code: '$generateCode' + } + }, + undefined + ) + + const doc = { + _id: generateId(), + _class: mockDocClass, + prefix: testPrefix, + space: mockSpaceId, + modifiedOn: platformNow(), + modifiedBy: generateId() as any + } - it('should handle unsupported types', async () => { - mockClient = createMockTxOperations([]) + const result = await dataMapper.prepareDocumentData(doc, mockSpaceId, false) - const dataMapper = new DataMapper( - mockContext, - mockClient, - state, - { - [mockDocClass]: { - code: '$ensureUnique' - } - }, - undefined - ) + expect(result.seqNumber).toBe(1) + expect(result.code).toBe('DOC-1') + }) - const doc = { + it('should generate code with different seqNumbers', async () => { + const testPrefix = 'DOC' + const existingDocs = [ + { _id: generateId(), _class: mockDocClass, - code: { complex: 'object' }, + seqNumber: 5, + prefix: testPrefix, space: mockSpaceId, modifiedOn: platformNow(), - modifiedBy: generateId() as any + modifiedBy: generateId() } + ] + mockClient = createMockTxOperations(existingDocs) + + const dataMapper = new DataMapper( + mockContext, + mockClient, + state, + { + [mockDocClass]: { + seqNumber: '$generateSeqNumber', + code: '$generateCode' + } + }, + undefined + ) + + const doc = { + _id: generateId(), + _class: mockDocClass, + prefix: testPrefix, + space: mockSpaceId, + modifiedOn: platformNow(), + modifiedBy: generateId() as any + } - const result = await dataMapper.prepareDocumentData(doc, mockSpaceId, false) + const result = await dataMapper.prepareDocumentData(doc, mockSpaceId, false) - // Should not modify unsupported types - expect(result.code).toEqual({ complex: 'object' }) - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockContext.warn).toHaveBeenCalledWith(expect.stringContaining('Cannot ensure uniqueness')) - }) + expect(result.seqNumber).toBe(6) + expect(result.code).toBe('DOC-6') + }) - it('should handle empty database with prefix pattern', async () => { - mockClient = createMockTxOperations([]) + it('should skip generation if prefix is missing', async () => { + mockClient = createMockTxOperations([]) - const dataMapper = new DataMapper( - mockContext, - mockClient, - state, - { - [mockDocClass]: { - code: '$ensureUnique' - } - }, - undefined - ) + const dataMapper = new DataMapper( + mockContext, + mockClient, + state, + { + [mockDocClass]: { + code: '$generateCode' + } + }, + undefined + ) + + const doc = { + _id: generateId(), + _class: mockDocClass, + seqNumber: 1, + space: mockSpaceId, + modifiedOn: platformNow(), + modifiedBy: generateId() as any + } - const doc = { - _id: generateId(), - _class: mockDocClass, - code: 'DOC-5', - space: mockSpaceId, - modifiedOn: platformNow(), - modifiedBy: generateId() as any - } + const result = await dataMapper.prepareDocumentData(doc, mockSpaceId, false) + + expect(result.code).toBeUndefined() + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockContext.warn).toHaveBeenCalledWith( + 'generateCode: prefix is required but not found, skipping code generation' + ) + }) - const result = await dataMapper.prepareDocumentData(doc, mockSpaceId, false) - - expect(result.code).toBe('DOC-5') - }) - - it('should handle multiple fields with $ensureUnique', async () => { - const testPrefix = 'DOC' - const existingDocs = [ - { - _id: generateId(), - _class: mockDocClass, - code: 'DOC-1', - seqNumber: 10, - prefix: testPrefix, - space: mockSpaceId, - modifiedOn: platformNow(), - modifiedBy: generateId() + it('should skip generation if seqNumber is missing', async () => { + const testPrefix = 'DOC' + mockClient = createMockTxOperations([]) + + const dataMapper = new DataMapper( + mockContext, + mockClient, + state, + { + [mockDocClass]: { + code: '$generateCode' } - ] - mockClient = createMockTxOperations(existingDocs) - - const dataMapper = new DataMapper( - mockContext, - mockClient, - state, - { - [mockDocClass]: { - code: '$ensureUnique', - seqNumber: '$ensureUnique' - } - }, - undefined - ) + }, + undefined + ) + + const doc = { + _id: generateId(), + _class: mockDocClass, + prefix: testPrefix, + space: mockSpaceId, + modifiedOn: platformNow(), + modifiedBy: generateId() as any + } + + const result = await dataMapper.prepareDocumentData(doc, mockSpaceId, false) - const doc = { + expect(result.code).toBeUndefined() + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockContext.warn).toHaveBeenCalledWith( + 'generateCode: seqNumber is required but not found, skipping code generation' + ) + }) + + it('should warn if generated code already exists', async () => { + const testPrefix = 'DOC' + const existingDocs = [ + { _id: generateId(), _class: mockDocClass, code: 'DOC-1', - seqNumber: 10, prefix: testPrefix, space: mockSpaceId, modifiedOn: platformNow(), - modifiedBy: generateId() as any + modifiedBy: generateId() } + ] + mockClient = createMockTxOperations(existingDocs) + + const dataMapper = new DataMapper( + mockContext, + mockClient, + state, + { + [mockDocClass]: { + seqNumber: '$generateSeqNumber', + code: '$generateCode' + } + }, + undefined + ) + + const doc = { + _id: generateId(), + _class: mockDocClass, + prefix: testPrefix, + space: mockSpaceId, + modifiedOn: platformNow(), + modifiedBy: generateId() as any + } + + const result = await dataMapper.prepareDocumentData(doc, mockSpaceId, false) + + // Should still generate the code (seqNumber will be 1, code will be DOC-1) + expect(result.seqNumber).toBe(1) + expect(result.code).toBe('DOC-1') + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockContext.warn).toHaveBeenCalledWith(expect.stringContaining('Generated code DOC-1 already exists')) + }) + + it('should handle prefix with special characters', async () => { + const testPrefix = 'DOC-TEST' + mockClient = createMockTxOperations([]) + + const dataMapper = new DataMapper( + mockContext, + mockClient, + state, + { + [mockDocClass]: { + seqNumber: '$generateSeqNumber', + code: '$generateCode' + } + }, + undefined + ) + + const doc = { + _id: generateId(), + _class: mockDocClass, + prefix: testPrefix, + space: mockSpaceId, + modifiedOn: platformNow(), + modifiedBy: generateId() as any + } - const result = await dataMapper.prepareDocumentData(doc, mockSpaceId, false) + const result = await dataMapper.prepareDocumentData(doc, mockSpaceId, false) - // Both fields should be made unique - expect(result.code).toBe('DOC-2') - expect(result.seqNumber).toBe(11) - }) + expect(result.seqNumber).toBe(1) + expect(result.code).toBe('DOC-TEST-1') }) }) diff --git a/services/export/pod-export/src/workspace/data-mapper.ts b/services/export/pod-export/src/workspace/data-mapper.ts index 4259b392838..41ab6eb4eca 100644 --- a/services/export/pod-export/src/workspace/data-mapper.ts +++ b/services/export/pod-export/src/workspace/data-mapper.ts @@ -102,7 +102,8 @@ export class DataMapper { * Field mappers format: { className: { fieldName: value, ... } } * Special values: * - '$currentUser' is replaced with current account's employee ID - * - '$ensureUnique' ensures the field value is unique by checking database and modifying if needed + * - '$generateSeqNumber' generates seqNumber based on minimum available value + * - '$generateCode' generates code from prefix and seqNumber */ private async applyFieldMappers (docClass: Ref>, data: Record): Promise { const hierarchy = this.targetClient.getHierarchy() @@ -151,9 +152,12 @@ export class DataMapper { } else { this.context.warn(`Cannot map ${fieldName}: $currentUser but current account employee not found`) } - } else if (fieldValue === '$ensureUnique') { - // Ensure field value is unique (globally, not per space) - await this.ensureFieldUnique(docClass, fieldName, data) + } else if (fieldValue === '$generateSeqNumber') { + // Generate seqNumber based on minimum available value + await this.generateSeqNumber(docClass, data) + } else if (fieldValue === '$generateCode') { + // Generate code from prefix and seqNumber + await this.generateCode(docClass, data) } else if (fieldValue === '') { // Empty string means clear the field data[fieldName] = undefined @@ -165,229 +169,119 @@ export class DataMapper { } /** - * Ensure a field value is unique by checking the database and modifying if needed. - * Uses the document's `prefix` field to query documents with the same template/prefix, - * ensuring uniqueness within that group. Falls back to global uniqueness if no prefix is available. - * - For documents with prefix: queries by prefix field and ensures uniqueness within that group - * - For documents without prefix: uses fallback queries (exact match for strings, >= for numbers) + * Generate seqNumber based on minimum available value. + * Uses max + 1 from existing seqNumbers with the same prefix. + * Similar to calculateNextSeqNumberWithCheck. */ - private async ensureFieldUnique ( - docClass: Ref>, - fieldName: string, - data: Record - ): Promise { - const currentValue = data[fieldName] - if (currentValue === undefined || currentValue === null) { + private async generateSeqNumber (docClass: Ref>, data: Record): Promise { + const documentPrefix = data.prefix + if (documentPrefix === undefined || typeof documentPrefix !== 'string' || documentPrefix === '') { + this.context.warn('generateSeqNumber: prefix is required but not found, skipping seqNumber generation') return } - // Initialize unique values tracking if not exists + // Query all documents with the same prefix + const query: any = { prefix: documentPrefix } + const projection = { seqNumber: 1, prefix: 1 } as any + + const existingDocs = await this.targetClient.findAll(docClass, query, { projection }) + + // Extract all seqNumbers from existing documents + const existingSeqNumbers = new Set() + for (const doc of existingDocs) { + const seqNum = (doc as any).seqNumber + if (seqNum !== undefined && seqNum !== null && typeof seqNum === 'number') { + existingSeqNumbers.add(seqNum) + } + } + + // Also check values used in this export batch for this specific prefix + // Use composite key to track seqNumbers per prefix + const seqNumberKey = `seqNumber:${documentPrefix}` + if (this.state.uniqueFieldValues !== undefined) { + const classKey = docClass + const fieldMap = this.state.uniqueFieldValues.get(classKey) + if (fieldMap !== undefined) { + const usedValues = fieldMap.get(seqNumberKey) + if (usedValues !== undefined) { + for (const usedValue of usedValues) { + if (typeof usedValue === 'number') { + existingSeqNumbers.add(usedValue) + } + } + } + } + } + + // Find next available seqNumber (max + 1) + const minAvailable = existingSeqNumbers.size > 0 ? Math.max(...Array.from(existingSeqNumbers)) + 1 : 1 + + data.seqNumber = minAvailable + + // Track this value in uniqueFieldValues per prefix if (this.state.uniqueFieldValues === undefined) { this.state.uniqueFieldValues = new Map() } - const classKey = docClass if (!this.state.uniqueFieldValues.has(classKey)) { this.state.uniqueFieldValues.set(classKey, new Map()) } - let fieldMap = this.state.uniqueFieldValues.get(classKey) if (fieldMap === undefined) { fieldMap = new Map() this.state.uniqueFieldValues.set(classKey, fieldMap) } - - let usedValues = fieldMap.get(fieldName) - if (usedValues === undefined) { - usedValues = new Set() - fieldMap.set(fieldName, usedValues) + if (!fieldMap.has(seqNumberKey)) { + fieldMap.set(seqNumberKey, new Set()) + } + const usedValues = fieldMap.get(seqNumberKey) + if (usedValues !== undefined) { + usedValues.add(minAvailable) } - const projection = { [fieldName]: 1 } as any - - let uniqueValue: string | number = currentValue - - if (typeof currentValue === 'string') { - const documentPrefix = data.prefix - if (documentPrefix !== undefined && typeof documentPrefix === 'string' && documentPrefix !== '') { - const codeMatch = currentValue.match(/-(\d+)$/) - const baseNum = codeMatch !== null ? parseInt(codeMatch[1], 10) : parseInt(currentValue, 10) - - const query: any = { prefix: documentPrefix } - - const prefixProjection = { [fieldName]: 1, prefix: 1 } as any - - const existingDocs = await this.targetClient.findAll(docClass, query, { projection: prefixProjection }) - - const existingValues = new Set() - for (const doc of existingDocs) { - const value = (doc as any)[fieldName] - if (value !== undefined && value !== null) { - existingValues.add(String(value)) - } - } - - for (const usedValue of usedValues) { - if (typeof usedValue === 'string') { - existingValues.add(usedValue) - } - } - - const isCurrentValueUnique = !existingValues.has(currentValue) - - if (isCurrentValueUnique) { - uniqueValue = currentValue - } else { - const existingNumbers = new Set() - for (const val of existingValues) { - const match = val.match(/-(\d+)$/) - if (match !== null) { - existingNumbers.add(parseInt(match[1], 10)) - } else { - const num = parseInt(val, 10) - if (!isNaN(num)) { - existingNumbers.add(num) - } - } - } - - const maxNum = existingNumbers.size > 0 ? Math.max(...Array.from(existingNumbers), baseNum - 1) : baseNum - 1 - // Generate new value: if original had pattern "PREFIX-N", use same pattern, otherwise just use number - if (codeMatch !== null) { - const originalPrefix = currentValue.substring(0, currentValue.lastIndexOf('-')) - uniqueValue = `${originalPrefix}-${maxNum + 1}` - } else { - uniqueValue = String(maxNum + 1) - } - this.context.info( - `ensureFieldUnique: ${fieldName} value ${currentValue} conflicts within prefix "${documentPrefix}", generating new value: ${uniqueValue}` - ) - } - } else { - // No prefix pattern, check if exact value exists - global uniqueness - const query: any = { [fieldName]: currentValue } - - const existing = await this.targetClient.findOne(docClass, query, { projection }) - const isUsedInBatch = usedValues.has(currentValue) - - if (existing === undefined && !isUsedInBatch) { - // Value is unique, use it as-is - uniqueValue = currentValue - } else { - // Value exists, append suffix - let attempt = 1 - while (attempt < 10) { - uniqueValue = `${currentValue}-${attempt}` - const checkQuery: any = { [fieldName]: uniqueValue } - const checkExisting = await this.targetClient.findOne(docClass, checkQuery, { projection }) - if (checkExisting === undefined && !usedValues.has(uniqueValue)) { - break - } - attempt++ - } - if (attempt >= 10) { - this.context.error( - `ensureFieldUnique: Failed to find unique value for field ${fieldName} after 10 attempts` - ) - return - } - } - } - } else if (typeof currentValue === 'number') { - // For numeric fields like seqNumber, check if document has a prefix field - // If so, ensure uniqueness within the same prefix (template) group - const documentPrefix = data.prefix - if (documentPrefix !== undefined && typeof documentPrefix === 'string' && documentPrefix !== '') { - // Query all documents with the same prefix (same template) - global uniqueness - const query: any = { prefix: documentPrefix } - - // Project both the field we're checking and prefix to verify - const prefixProjection = { [fieldName]: 1, prefix: 1 } as any - - const existingDocs = await this.targetClient.findAll(docClass, query, { projection: prefixProjection }) - - // Extract all numbers from existing values - const existingNumbers = new Set() - for (const doc of existingDocs) { - const value = (doc as any)[fieldName] - if (typeof value === 'number') { - existingNumbers.add(value) - } - } - - // Also check values used in this export batch - for (const usedValue of usedValues) { - if (typeof usedValue === 'number') { - existingNumbers.add(usedValue) - } - } - - // Check if current value is already unique - const isCurrentValueUnique = !existingNumbers.has(currentValue) && !usedValues.has(currentValue) - - if (isCurrentValueUnique) { - // Current value is unique, keep it - uniqueValue = currentValue - } else { - // Find max number and use max + 1 - const maxNum = existingNumbers.size > 0 ? Math.max(...Array.from(existingNumbers)) : currentValue - 1 - uniqueValue = maxNum + 1 - this.context.info( - `ensureFieldUnique: ${fieldName} value ${currentValue} conflicts within prefix "${documentPrefix}", generating new value: ${uniqueValue}` - ) - } - } else { - // No prefix field, fall back to querying all values >= current - global uniqueness - const query: any = { [fieldName]: { $gte: currentValue } } - - const existingDocs = await this.targetClient.findAll(docClass, query, { projection }) - - // Extract all numbers from existing values - const existingNumbers = new Set() - for (const doc of existingDocs) { - const value = (doc as any)[fieldName] - if (typeof value === 'number') { - existingNumbers.add(value) - } - } + this.context.info( + `generateSeqNumber: Generated seqNumber ${minAvailable} for prefix "${documentPrefix}" (class: ${docClass})` + ) + } - // Also check values used in this export batch - for (const usedValue of usedValues) { - if (typeof usedValue === 'number' && usedValue >= currentValue) { - existingNumbers.add(usedValue) - } - } + /** + * Generate code from prefix and seqNumber using the pattern prefix-seqNumber. + * Requires both prefix and seqNumber to be set in data. + */ + private async generateCode (docClass: Ref>, data: Record): Promise { + const prefix = data.prefix + const seqNumber = data.seqNumber - // Check if current value is already unique - const isCurrentValueUnique = !existingNumbers.has(currentValue) && !usedValues.has(currentValue) + if (prefix === undefined || typeof prefix !== 'string' || prefix === '') { + this.context.warn('generateCode: prefix is required but not found, skipping code generation') + return + } - if (isCurrentValueUnique) { - // Current value is unique, keep it - uniqueValue = currentValue - } else { - // Find max number and use max + 1 - const maxNum = existingNumbers.size > 0 ? Math.max(...Array.from(existingNumbers)) : currentValue - 1 - uniqueValue = maxNum + 1 - this.context.info( - `ensureFieldUnique: ${fieldName} value ${currentValue} conflicts, generating new value: ${uniqueValue}` - ) - } - } - } else { - // Unsupported type, skip uniqueness check - this.context.warn(`Cannot ensure uniqueness for field ${fieldName} with type ${typeof currentValue}`) + if (seqNumber === undefined || seqNumber === null || typeof seqNumber !== 'number') { + this.context.warn('generateCode: seqNumber is required but not found, skipping code generation') return } - // Update data with unique value - data[fieldName] = uniqueValue - usedValues.add(uniqueValue) + // Generate code using pattern: prefix-seqNumber + const generatedCode = `${prefix}-${seqNumber}` + + // Check if this code already exists (shouldn't happen if seqNumber was generated correctly, but check anyway) + const query: any = { code: generatedCode } + const projection = { code: 1 } as any + const existing = await this.targetClient.findOne(docClass, query, { projection }) - if (uniqueValue !== currentValue) { - this.context.info( - `ensureFieldUnique: Updated ${fieldName} from ${currentValue} to ${uniqueValue} (class: ${docClass})` + if (existing !== undefined) { + this.context.warn( + `generateCode: Generated code ${generatedCode} already exists, this should not happen if seqNumber was generated correctly` ) } + + // Update data with generated code + data.code = generatedCode + + this.context.info( + `generateCode: Generated code ${generatedCode} from prefix "${prefix}" and seqNumber ${seqNumber} (class: ${docClass})` + ) } /**