diff --git a/foundations/core/packages/core/src/__tests__/identifier.test.ts b/foundations/core/packages/core/src/__tests__/identifier.test.ts new file mode 100644 index 0000000000..812f28d50c --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/identifier.test.ts @@ -0,0 +1,297 @@ +// +// Copyright © 2026 TraceX SAS. +// +// Licensed under the PolyForm Shield License 1.0.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://polyformproject.org/licenses/shield/1.0.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type CustomSequence, type Ref, type TxOperations } from '..' +import { + allocateWithRetries, + parseIdentifier, + requestIdentifierAllocation, + requestNextIdentifier, + requestNumberAllocation +} from '../identifier' + +class SequenceTestClient { + private sequence: CustomSequence | undefined + + constructor (private readonly rejectCreation: boolean = false) {} + + async findOne (_class: string, query: { _id?: string }): Promise { + if (query._id !== undefined && query._id !== this.sequence?._id) return undefined + return this.sequence + } + + apply (): { + notMatch: () => void + createDoc: (_class: string, _space: string, data: CustomSequence, id: Ref) => Promise + commit: () => Promise<{ result: boolean }> + } { + let created: CustomSequence | undefined + return { + notMatch: () => {}, + createDoc: async (_class, _space, data, id) => { + created = { ...data, _id: id } + }, + commit: async () => { + if (this.rejectCreation) return { result: false } + if (this.sequence !== undefined || created === undefined) return { result: false } + this.sequence = created + return { result: true } + } + } + } + + async update ( + sequence: CustomSequence, + operations: { $inc: { sequence: number } } + ): Promise<{ object: CustomSequence }> { + sequence.sequence += operations.$inc.sequence + return { object: { ...sequence } } + } +} + +function createClient (rejectCreation: boolean = false): TxOperations { + return new SequenceTestClient(rejectCreation) as unknown as TxOperations +} + +describe('identifier allocation', () => { + it('atomically advances a numeric sequence', async () => { + const client = createClient() + + await expect( + requestNumberAllocation(client, { namespace: 'documents.sequence', sequence: 'seqNumber' }) + ).resolves.toBe(1) + await expect( + requestNumberAllocation(client, { namespace: 'documents.sequence', sequence: 'seqNumber' }) + ).resolves.toBe(2) + }) + + it('advances directly to the requested minimum', async () => { + await expect( + requestNumberAllocation(createClient(), { + namespace: 'documents.sequence', + sequence: 'seqNumber', + minimum: 12 + }) + ).resolves.toBe(12) + }) + + it('formats identifiers from the allocated sequence', async () => { + await expect( + requestIdentifierAllocation(createClient(), { + namespace: 'documents', + prefix: 'TEST-TMP', + minimum: 5 + }) + ).resolves.toBe('TEST-TMP-5') + }) + + it('returns distinct values for concurrent requests', async () => { + const client = createClient() + const values = await Promise.all( + Array.from( + { length: 8 }, + async () => await requestNumberAllocation(client, { namespace: 'documents.sequence', sequence: 'seqNumber' }) + ) + ) + + expect(values.sort((left, right) => left - right)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]) + }) + + it('rejects invalid allocation input', async () => { + await expect(requestNumberAllocation(createClient(), { namespace: '', sequence: 'seqNumber' })).rejects.toThrow( + 'namespace and sequence are required' + ) + }) + + it('stops sequence initialization after ten conflicts', async () => { + await expect( + requestNumberAllocation(createClient(true), { namespace: 'documents.sequence', sequence: 'seqNumber' }) + ).rejects.toThrow('after 10 attempts') + }) + + it('parses prefixes containing hyphens', () => { + expect(parseIdentifier('TEST-TMP-1')).toEqual({ prefix: 'TEST-TMP', sequence: 1 }) + expect(parseIdentifier('TEST-TMP')).toBeNull() + }) + + it('rejects prefixes that are not identifiers', () => { + expect(parseIdentifier('My doc-1')).toBeNull() + expect(parseIdentifier('a/b-1')).toBeNull() + expect(parseIdentifier('-1')).toBeNull() + expect(parseIdentifier('TEST-0')).toBeNull() + }) +}) + +/** Client holding a sequence per namespace, scope and prefix. */ +class MultiSequenceTestClient { + private readonly sequences = new Map() + + private key (namespace: string, scope: string, prefix: string): string { + return `${namespace}|${scope}|${prefix}` + } + + async findOne (_class: string, query: Record): Promise { + if (typeof query._id === 'string') return this.sequences.get(query._id) + return this.sequences.get(this.key(query.namespace, query.scope, query.prefix)) + } + + apply (): { + notMatch: () => void + createDoc: (_class: string, _space: string, data: CustomSequence) => Promise + commit: () => Promise<{ result: boolean }> + } { + let created: CustomSequence | undefined + return { + notMatch: () => {}, + createDoc: async (_class, _space, data) => { + const id = this.key(data.namespace ?? '', data.scope ?? '', data.prefix) as Ref + created = { ...data, _id: id } + }, + commit: async () => { + if (created === undefined) return { result: false } + this.sequences.set(created._id, created) + return { result: true } + } + } + } + + async update (sequence: CustomSequence, operations: { $inc: { sequence: number } }): Promise { + const stored = this.sequences.get(sequence._id) ?? sequence + stored.sequence += operations.$inc.sequence + return { object: { ...stored } } + } +} + +function multiClient (): TxOperations { + return new MultiSequenceTestClient() as unknown as TxOperations +} + +const noConflicts = async (): Promise<{ sequence: boolean, code: boolean }> => ({ sequence: false, code: false }) + +describe('allocateWithRetries', () => { + const sequenceRequest = { namespace: 'documents.sequence', scope: 'template-1', sequence: 'seqNumber' } + + it('derives the code from the allocated number', async () => { + const attempt = jest.fn(async () => true) + + await expect( + allocateWithRetries(multiClient(), { ...sequenceRequest, minimum: 4, codePrefix: 'QMS' }, attempt, noConflicts) + ).resolves.toEqual({ seqNumber: 4, code: 'QMS-4', success: true }) + expect(attempt).toHaveBeenCalledWith(4, 'QMS-4') + }) + + it('takes the next number and code when the number is taken', async () => { + const attempt = jest.fn(async (_seqNumber: number, code: string) => code !== 'QMS-1') + + await expect( + allocateWithRetries(multiClient(), { ...sequenceRequest, codePrefix: 'QMS' }, attempt, async () => ({ + sequence: true, + code: false + })) + ).resolves.toMatchObject({ seqNumber: 2, code: 'QMS-2', success: true }) + expect(attempt).toHaveBeenCalledTimes(2) + }) + + it('keeps the number and renumbers a custom code from its own sequence', async () => { + const attempt = jest.fn(async (_seqNumber: number, code: string) => code !== 'LEGACY-3') + + await expect( + allocateWithRetries( + multiClient(), + { ...sequenceRequest, minimum: 7, code: 'LEGACY-3', codeNamespace: 'documents' }, + attempt, + async () => ({ sequence: false, code: true }) + ) + ).resolves.toMatchObject({ seqNumber: 7, code: 'LEGACY-4', success: true }) + expect(attempt).toHaveBeenNthCalledWith(2, 7, 'LEGACY-4') + }) + + it('keeps a code that is not an identifier as it is', async () => { + const attempt = jest.fn(async () => true) + + await expect( + allocateWithRetries( + multiClient(), + { ...sequenceRequest, code: 'a1b2', codeNamespace: 'documents' }, + attempt, + noConflicts + ) + ).resolves.toMatchObject({ seqNumber: 1, code: 'a1b2', success: true }) + expect(attempt).toHaveBeenCalledWith(1, 'a1b2') + }) + + it('gives up on a taken code that cannot be renumbered', async () => { + const attempt = jest.fn(async () => false) + + await expect( + allocateWithRetries( + multiClient(), + { ...sequenceRequest, code: 'a1b2', codeNamespace: 'documents' }, + attempt, + async () => ({ sequence: false, code: true }) + ) + ).resolves.toMatchObject({ success: false, reason: 'the code a1b2 is already taken' }) + expect(attempt).toHaveBeenCalledTimes(1) + }) + + it('reports a failure that no conflict explains', async () => { + await expect( + allocateWithRetries(multiClient(), { ...sequenceRequest, codePrefix: 'QMS' }, async () => false, noConflicts) + ).resolves.toMatchObject({ success: false, reason: 'creation failed without an identifier conflict' }) + }) + + it('reports an aborted allocation', async () => { + await expect( + allocateWithRetries( + multiClient(), + { ...sequenceRequest, codePrefix: 'QMS' }, + async () => false, + async () => ({ sequence: true, code: false }), + async () => true + ) + ).resolves.toMatchObject({ success: false, reason: 'the allocation was aborted' }) + }) + + it('stops after ten conflicting attempts', async () => { + const attempt = jest.fn(async () => false) + + await expect( + allocateWithRetries(multiClient(), { ...sequenceRequest, codePrefix: 'QMS' }, attempt, async () => ({ + sequence: true, + code: false + })) + ).resolves.toMatchObject({ success: false, reason: 'no free identifier after 10 attempts' }) + expect(attempt).toHaveBeenCalledTimes(10) + }) + + it('rejects a request without exactly one code source', async () => { + await expect( + allocateWithRetries(multiClient(), { ...sequenceRequest }, async () => true, noConflicts) + ).rejects.toThrow('either a code prefix or a code') + await expect( + allocateWithRetries(multiClient(), { ...sequenceRequest, code: 'LEGACY-3' }, async () => true, noConflicts) + ).rejects.toThrow('a namespace to renumber a custom code from') + }) +}) + +describe('requestNextIdentifier', () => { + it('allocates the first identifier after an occupied one', async () => { + await expect(requestNextIdentifier(multiClient(), 'CC-8', 'documents')).resolves.toBe('CC-9') + }) + + it('rejects a value that is not an identifier', async () => { + await expect(requestNextIdentifier(multiClient(), 'CC', 'documents')).rejects.toThrow('Invalid identifier: CC') + }) +}) diff --git a/foundations/core/packages/core/src/classes.ts b/foundations/core/packages/core/src/classes.ts index f0feceb209..8ec7d70c3d 100644 --- a/foundations/core/packages/core/src/classes.ts +++ b/foundations/core/packages/core/src/classes.ts @@ -705,6 +705,10 @@ export interface Sequence extends Doc { export interface CustomSequence extends Sequence { prefix: string + /** Feature the sequence belongs to, e.g. `documents` or `documents.sequence`. */ + namespace?: string + /** What the numbering is counted per within the namespace, e.g. a template id. */ + scope?: string } /** diff --git a/foundations/core/packages/core/src/identifier.ts b/foundations/core/packages/core/src/identifier.ts new file mode 100644 index 0000000000..93e1d7a84e --- /dev/null +++ b/foundations/core/packages/core/src/identifier.ts @@ -0,0 +1,240 @@ +// +// Copyright © 2026 TraceX SAS. +// +// Licensed under the PolyForm Shield License 1.0.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://polyformproject.org/licenses/shield/1.0.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import core from './component' +import { type CustomSequence } from './classes' +import { type TxOperations } from './operations' +import { generateId } from './utils' + +export interface AllocationRequest { + /** Feature the sequence belongs to, e.g. `documents` or `documents.sequence`. */ + namespace: string + /** What the numbering is counted per within the namespace, e.g. a template id. */ + scope?: string + /** Lowest acceptable value, defaults to 1. */ + minimum?: number +} + +export interface NumberAllocationRequest extends AllocationRequest { + /** Name of the sequence within the namespace and scope. */ + sequence: string +} + +export interface IdentifierAllocationRequest extends AllocationRequest { + /** Identifier prefix, which also names the sequence the number comes from. */ + prefix: string +} + +export interface ParsedIdentifier { + prefix: string + sequence: number +} + +/** + * Parses identifiers with a positive numeric suffix and a prefix of word characters, + * which may itself contain hyphens: `TEST-TMP-1` is `TEST-TMP` and `1`. + */ +export function parseIdentifier (code: string): ParsedIdentifier | null { + const match = code.match(/^(?[\w-]+)-(?\d+)$/) + const prefix = match?.groups?.prefix + const sequence = match?.groups?.sequence + if (prefix === undefined || sequence === undefined) return null + + const parsedSequence = Number(sequence) + if (!Number.isSafeInteger(parsedSequence) || parsedSequence < 1) return null + return { prefix, sequence: parsedSequence } +} + +const MAX_ALLOCATION_ATTEMPTS = 10 + +/** + * Atomically increments a named sequence and returns its actual stored value. + * + * Every caller gets the result of its own increment, so returned values are always distinct. + * Reaching `minimum` may take a second increment, which leaves a gap in the sequence + * but never hands the same value to two callers. + */ +export async function requestNumberAllocation (client: TxOperations, request: NumberAllocationRequest): Promise { + if (request.namespace.trim() === '' || request.sequence.trim() === '') { + throw new Error('Allocation namespace and sequence are required') + } + + const minimum = request.minimum ?? 1 + if (!Number.isSafeInteger(minimum) || minimum < 1) { + throw new Error('Allocation minimum must be a positive integer') + } + + const scope = request.scope ?? '' + const query = { namespace: request.namespace, scope, prefix: request.sequence } + + for (let attempt = 0; attempt < MAX_ALLOCATION_ATTEMPTS; attempt++) { + let sequence = await client.findOne(core.class.CustomSequence, query) + if (sequence === undefined) { + const sequenceId = generateId() + const operations = client.apply('create-custom-sequence') + operations.notMatch(core.class.CustomSequence, query) + await operations.createDoc( + core.class.CustomSequence, + core.space.Workspace, + { + attachedTo: core.class.CustomSequence, + namespace: request.namespace, + scope, + prefix: request.sequence, + sequence: 0 + }, + sequenceId + ) + if (!(await operations.commit()).result) continue + sequence = await client.findOne(core.class.CustomSequence, { _id: sequenceId }) + if (sequence === undefined) continue + } + + const increment = await client.update(sequence, { $inc: { sequence: 1 } }, true) + let value = (increment as { object: CustomSequence }).object.sequence + if (value < minimum) { + const advance = await client.update(sequence, { $inc: { sequence: minimum - value } }, true) + value = (advance as { object: CustomSequence }).object.sequence + } + return value + } + + throw new Error(`Unable to initialize sequence after ${MAX_ALLOCATION_ATTEMPTS} attempts`) +} + +/** Allocates the next identifier from an atomically incremented prefix sequence. */ +export async function requestIdentifierAllocation ( + client: TxOperations, + request: IdentifierAllocationRequest +): Promise { + if (request.prefix.trim() === '') { + throw new Error('Identifier prefix is required') + } + + const sequence = await requestNumberAllocation(client, { + namespace: request.namespace, + scope: request.scope, + sequence: request.prefix, + minimum: request.minimum + }) + return `${request.prefix}-${sequence}` +} + +export interface AllocationConflicts { + /** The allocated number is already taken. */ + sequence: boolean + /** The code is already taken. */ + code: boolean +} + +export interface RetriedAllocationRequest extends NumberAllocationRequest { + /** Prefix the code is derived from, when the code follows the allocated number. */ + codePrefix?: string + /** + * Code to start from, when it does not follow the allocated number. A code that is not an + * identifier is used as is, since there is no sequence to renumber it from on a conflict. + */ + code?: string + /** Namespace of the per-prefix sequence a custom code is renumbered from. */ + codeNamespace?: string + /** Scope of the per-prefix sequence a custom code is renumbered from. */ + codeScope?: string +} + +export interface AllocationOutcome { + seqNumber: number + code: string + success: boolean + /** Why the allocation was given up on, when it did not succeed. */ + reason?: string +} + +/** + * Allocates a number and a code, and retries the creation while either of them is taken + * by a concurrent one. A failure with no conflicting document is not an allocation problem, + * so it is reported instead of retried. + * + * `attempt` is expected to create the document in a single guarded transaction and to + * return whether it applied, `findConflicts` to report which of the two values it lost to. + */ +export async function allocateWithRetries ( + client: TxOperations, + request: RetriedAllocationRequest, + attempt: (seqNumber: number, code: string) => Promise, + findConflicts: (seqNumber: number, code: string) => Promise, + isAborted?: () => Promise +): Promise { + const { codePrefix, code: requestedCode, codeNamespace, codeScope, ...sequence } = request + if ((codePrefix === undefined) === (requestedCode === undefined)) { + throw new Error('Allocation requires either a code prefix or a code to start from') + } + if (codePrefix === undefined && codeNamespace === undefined) { + throw new Error('Allocation requires a namespace to renumber a custom code from') + } + + let seqNumber = await requestNumberAllocation(client, sequence) + let code = codePrefix !== undefined ? `${codePrefix}-${seqNumber}` : (requestedCode as string) + + for (let attemptIndex = 0; attemptIndex < MAX_ALLOCATION_ATTEMPTS; attemptIndex++) { + if (await attempt(seqNumber, code)) return { seqNumber, code, success: true } + if (isAborted !== undefined && (await isAborted())) { + return { seqNumber: -1, code, success: false, reason: 'the allocation was aborted' } + } + + const conflicts = await findConflicts(seqNumber, code) + if (!conflicts.sequence && !conflicts.code) { + return { seqNumber: -1, code, success: false, reason: 'creation failed without an identifier conflict' } + } + + if (conflicts.sequence || codePrefix !== undefined) { + seqNumber = await requestNumberAllocation(client, { ...sequence, minimum: undefined }) + } + if (codePrefix !== undefined) { + code = `${codePrefix}-${seqNumber}` + } else if (conflicts.code) { + if (parseIdentifier(code) === null) { + return { seqNumber: -1, code, success: false, reason: `the code ${code} is already taken` } + } + code = await requestNextIdentifier(client, code, codeNamespace as string, codeScope) + } + } + + return { + seqNumber: -1, + code, + success: false, + reason: `no free identifier after ${MAX_ALLOCATION_ATTEMPTS} attempts` + } +} + +/** Allocates the first identifier of the same prefix that comes after an occupied one. */ +export async function requestNextIdentifier ( + client: TxOperations, + occupiedCode: string, + namespace: string, + scope?: string +): Promise { + const parsedCode = parseIdentifier(occupiedCode) + if (parsedCode === null) { + throw new Error(`Invalid identifier: ${occupiedCode}`) + } + + return await requestIdentifierAllocation(client, { + namespace, + scope, + prefix: parsedCode.prefix, + minimum: parsedCode.sequence + 1 + }) +} diff --git a/foundations/core/packages/core/src/index.ts b/foundations/core/packages/core/src/index.ts index ddafa5c49d..1538507cd9 100644 --- a/foundations/core/packages/core/src/index.ts +++ b/foundations/core/packages/core/src/index.ts @@ -27,6 +27,7 @@ export { configUserAccountUuid } from './component' export * from './hierarchy' +export * from './identifier' export * from '@hcengineering/measurements' export * from './memdb' export * from './objvalue' diff --git a/models/controlled-documents/src/migration.ts b/models/controlled-documents/src/migration.ts index d9be18a157..577184763c 100644 --- a/models/controlled-documents/src/migration.ts +++ b/models/controlled-documents/src/migration.ts @@ -1,5 +1,6 @@ // // Copyright @ 2022-2023 Hardcore Engineering Inc. +// Copyright © 2026 TraceX SAS. // import attachment, { type Attachment } from '@hcengineering/attachment' @@ -18,14 +19,17 @@ import { createDocumentTemplate, type DocumentApprovalRequest, type DocumentCategory, + type Document, type DocumentMeta, type DocumentReviewRequest, documentsId, DocumentState, + matchDocumentId, type ProjectMeta } from '@hcengineering/controlled-documents' import { type Class, + type CustomSequence, type Data, type Doc, DOMAIN_SEQUENCE, @@ -184,6 +188,96 @@ async function createTemplateSequence (tx: TxOperations): Promise { } } +interface DuplicateDocumentSequence { + scope: string + value: number + documents: Array> +} + +interface DocumentSequenceOwner { + document: Ref + code: string +} + +async function synchronizeDocumentSequences (tx: TxOperations): Promise { + const documentsWithCodes = await tx.findAll( + documents.class.Document, + {}, + { projection: { code: 1, seqNumber: 1, template: 1 } } + ) + const sequenceByPrefix = new Map() + const numberSequenceByScope = new Map() + const sequenceOwners = new Map() + + for (const document of documentsWithCodes) { + const parsedCode = matchDocumentId(document.code) + if (parsedCode !== null) { + const current = sequenceByPrefix.get(parsedCode.prefix) ?? 0 + sequenceByPrefix.set(parsedCode.prefix, Math.max(current, parsedCode.seqNumber)) + } + + if (Number.isSafeInteger(document.seqNumber) && document.seqNumber > 0) { + const scope = document.template ?? 'templates' + numberSequenceByScope.set(scope, Math.max(numberSequenceByScope.get(scope) ?? 0, document.seqNumber)) + const ownerKey = `${scope}\u0000${document.seqNumber}` + const owners = sequenceOwners.get(ownerKey) ?? [] + owners.push({ document: document._id, code: document.code }) + sequenceOwners.set(ownerKey, owners) + } + } + + for (const [prefix, sequence] of sequenceByPrefix) { + const existing = await tx.findOne(core.class.CustomSequence, { + namespace: documentsId, + scope: '', + prefix + }) + if (existing === undefined) { + await tx.createDoc(core.class.CustomSequence, core.space.Workspace, { + attachedTo: core.class.CustomSequence, + namespace: documentsId, + scope: '', + prefix, + sequence + }) + } else if (existing.sequence < sequence) { + await tx.updateDoc(existing._class, existing.space, existing._id, { sequence }) + } + } + + for (const [scope, sequence] of numberSequenceByScope) { + const existing = await tx.findOne(core.class.CustomSequence, { + namespace: `${documentsId}.sequence`, + scope, + prefix: 'seqNumber' + }) + if (existing === undefined) { + await tx.createDoc(core.class.CustomSequence, core.space.Workspace, { + attachedTo: core.class.CustomSequence, + namespace: `${documentsId}.sequence`, + scope, + prefix: 'seqNumber', + sequence + }) + } else if (existing.sequence < sequence) { + await tx.updateDoc(existing._class, existing.space, existing._id, { sequence }) + } + } + + // Versions of one document legitimately share a sequence and a code, + // so only distinct codes on the same sequence mean an actual collision. + return Array.from(sequenceOwners.entries()) + .filter(([, owners]) => new Set(owners.map(({ code }) => code)).size > 1) + .map(([ownerKey, owners]) => { + const separator = ownerKey.indexOf('\u0000') + return { + scope: ownerKey.slice(0, separator), + value: Number(ownerKey.slice(separator + 1)), + documents: owners.map(({ document }) => document) + } + }) +} + async function createDocumentCategories (tx: TxOperations): Promise { const categories: Pick, 'code' | 'title'>[] = [ { code: 'CA', title: 'CAPA (Corrective and Preventive Action)' }, @@ -585,6 +679,16 @@ export const documentsOperation: MigrateOperation = { await createDocumentCategories(tx) await createProductChangeControlTemplate(tx) } + }, + { + state: 'sync-document-number-sequences', + func: async (client) => { + const tx = new TxOperations(client, core.account.System) + const duplicates = await synchronizeDocumentSequences(tx) + for (const duplicate of duplicates) { + console.warn('Duplicate controlled document sequence detected', duplicate) + } + } } ]) } diff --git a/models/core/src/core.ts b/models/core/src/core.ts index 2c3af463c0..b1c4b8f5fe 100644 --- a/models/core/src/core.ts +++ b/models/core/src/core.ts @@ -438,6 +438,8 @@ export class TSequence extends TDoc implements Sequence { @Model(core.class.CustomSequence, core.class.Sequence) export class TCustomSequence extends TSequence implements CustomSequence { prefix!: string + namespace?: string + scope?: string } @Model(core.class.ClassCollaborators, core.class.Doc, DOMAIN_MODEL) diff --git a/packages/importer/src/importer/importer.ts b/packages/importer/src/importer/importer.ts index 5c5d30b7b5..e071660496 100644 --- a/packages/importer/src/importer/importer.ts +++ b/packages/importer/src/importer/importer.ts @@ -1,5 +1,6 @@ // // Copyright © 2024 Hardcore Engineering Inc. +// Copyright © 2026 TraceX SAS. // // Licensed under the Eclipse Public License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. You may @@ -25,8 +26,13 @@ import documents, { type DocumentSpace, type DocumentState, type DocumentTemplate, + type Document as ControlledDocumentBase, + allocateDocumentIdentifier, + type DocumentAllocation, type OrgSpace, type ProjectDocument, + TEMPLATE_PREFIX, + TEMPLATE_SEQUENCE_SCOPE, useDocumentTemplate } from '@hcengineering/controlled-documents' import core, { @@ -39,6 +45,7 @@ import core, { type DocumentQuery, generateId, makeCollabId, + parseIdentifier, type Mixin, type Blob as PlatformBlob, type Ref, @@ -74,6 +81,7 @@ import { type Props, type UnifiedUpdate, type UnifiedDoc, type UnifiedFile, type import { type Logger } from './logger' import { type MarkdownPreprocessor, NoopMarkdownPreprocessor } from './preprocessor' import { type FileUploader } from './uploader' + export interface ImportWorkspace { projectTypes?: ImportProjectType[] spaces?: ImportSpace[] @@ -984,48 +992,66 @@ export class WorkspaceImporter { template.ccImpact ) - const ops = this.client.apply() - const result = await ops.addCollection( - documents.class.ControlledDocument, - spaceId, - template.metaId, - documents.class.DocumentMeta, - 'documents', + // A code that is not an identifier is imported as is. + const parsedCode = parseIdentifier(code) + const usesSequenceCode = parsedCode?.prefix === TEMPLATE_PREFIX + + await this.createWithAllocation( + template.title, { - title: template.title, - major: template.major, - minor: template.minor, - state: template.state, - author: template.author, - owner: template.owner, - abstract: template.abstract, - category: template.category, - reviewers: template.reviewers ?? [], - approvers: template.approvers ?? [], - externalApprovers: template.externalApprovers ?? [], - coAuthors: template.coAuthors ?? [], - code, - seqNumber, - prefix: template.docPrefix, - content: contentId, - changeControl: changeControlId, - commentSequence: 0, - requests: 0, - labels: 0 + scope: TEMPLATE_SEQUENCE_SCOPE, + minimum: Math.max(seqNumber, 1), + conflictQuery: { template: { $exists: false } }, + codePrefix: usesSequenceCode ? TEMPLATE_PREFIX : undefined, + code: usesSequenceCode ? undefined : code }, - template.id as unknown as Ref - ) - - await ops.createMixin(template.id, documents.class.Document, spaceId, documents.mixin.DocumentTemplate, { - sequence: 0, - docPrefix: template.docPrefix - }) + async (seqNumber, code) => { + const ops = this.client.apply('create-imported-qms-document') + ops.notMatch(documents.class.Document, { template: { $exists: false }, seqNumber }) + ops.notMatch(documents.class.Document, { code }) + await ops.addCollection( + documents.class.ControlledDocument, + spaceId, + template.metaId, + documents.class.DocumentMeta, + 'documents', + { + title: template.title, + major: template.major, + minor: template.minor, + state: template.state, + author: template.author, + owner: template.owner, + abstract: template.abstract, + category: template.category, + reviewers: template.reviewers ?? [], + approvers: template.approvers ?? [], + externalApprovers: template.externalApprovers ?? [], + coAuthors: template.coAuthors ?? [], + code, + seqNumber, + prefix: template.docPrefix, + content: contentId, + changeControl: changeControlId, + commentSequence: 0, + requests: 0, + labels: 0 + }, + template.id as unknown as Ref + ) - const commit = await ops.commit() - if (!commit.result) { - throw new Error('Failed to create document template attached doc: ' + template.title) - } + await ops.createMixin(template.id, documents.class.Document, spaceId, documents.mixin.DocumentTemplate, { + sequence: 0, + docPrefix: template.docPrefix + }) + await ops.updateDoc(documents.class.DocumentMeta, spaceId, template.metaId, { + title: `${code} ${template.title}` + }) + return (await ops.commit()).result + } + ) + const result = template.id as unknown as Ref this.logger.log('Document template attached doc created: ' + result) return result } @@ -1091,12 +1117,14 @@ export class WorkspaceImporter { const templateId = document.template const { - seqNumber, + seqNumber: minimum, prefix, - category: templateCategory + category: templateCategory, + templateSpace } = await useDocumentTemplate(this.client, templateId as unknown as Ref) - - const ops = this.client.apply() + if (minimum < 1) { + throw new Error(`Document template not found: ${templateId}`) + } const changeControlId = await this.createChangeControl( spaceId, @@ -1105,48 +1133,89 @@ export class WorkspaceImporter { document.ccImpact ) - const code = document.code ?? `${prefix}-${seqNumber}` - const result = await ops.addCollection( - documents.class.ControlledDocument, - spaceId, - document.metaId, - documents.class.DocumentMeta, - 'documents', + // A code that is not an identifier is imported as is. + const requestedCode = document.code + const parsedCode = requestedCode === undefined ? undefined : parseIdentifier(requestedCode) + const usesSequenceCode = parsedCode === undefined || parsedCode?.prefix === prefix + + await this.createWithAllocation( + document.title, { - title: document.title, - major: document.major, - minor: document.minor, - state: document.state, - author: document.author, - owner: document.owner, - abstract: document.abstract, - reviewers: document.reviewers ?? [], - approvers: document.approvers ?? [], - externalApprovers: document.externalApprovers ?? [], - coAuthors: document.coAuthors ?? [], - changeControl: changeControlId, - code, - prefix, - category: document.category ?? templateCategory, - seqNumber, - content: contentId, - template: templateId as unknown as Ref, - commentSequence: 0, - requests: 0 + scope: templateId, + minimum, + conflictQuery: { template: templateId as unknown as Ref }, + codePrefix: usesSequenceCode ? prefix : undefined, + code: usesSequenceCode ? undefined : requestedCode }, - document.id - ) + async (seqNumber, code) => { + const ops = this.client.apply('create-imported-qms-document') + ops.notMatch(documents.class.Document, { + template: templateId as unknown as Ref, + seqNumber + }) + ops.notMatch(documents.class.Document, { code }) + await ops.addCollection( + documents.class.ControlledDocument, + spaceId, + document.metaId, + documents.class.DocumentMeta, + 'documents', + { + title: document.title, + major: document.major, + minor: document.minor, + state: document.state, + author: document.author, + owner: document.owner, + abstract: document.abstract, + reviewers: document.reviewers ?? [], + approvers: document.approvers ?? [], + externalApprovers: document.externalApprovers ?? [], + coAuthors: document.coAuthors ?? [], + changeControl: changeControlId, + code, + prefix, + category: document.category ?? templateCategory, + seqNumber, + content: contentId, + template: templateId as unknown as Ref, + commentSequence: 0, + requests: 0 + }, + document.id + ) - await ops.updateDoc(documents.class.DocumentMeta, spaceId, document.metaId, { - documents: 0, - title: `${code} ${document.title}` - }) + await ops.updateDoc(documents.class.DocumentMeta, spaceId, document.metaId, { + documents: 0, + title: `${code} ${document.title}` + }) + // Best effort hint for the UI: the custom sequence stays the source of truth. + await ops.updateMixin( + templateId as unknown as Ref, + documents.class.Document, + templateSpace, + documents.mixin.DocumentTemplate, + { sequence: seqNumber } + ) + return (await ops.commit()).result + } + ) - await ops.commit() + this.logger.log('Controlled document attached doc created: ' + document.id) - this.logger.log('Controlled document attached doc created: ' + result) + return document.id + } - return result + /** Creates a controlled document, retrying while its number or code is taken. */ + private async createWithAllocation ( + title: string, + allocation: DocumentAllocation, + attempt: (seqNumber: number, code: string) => Promise + ): Promise { + const { success, reason } = await allocateDocumentIdentifier(this.client, allocation, attempt) + if (!success) { + throw new Error(`Failed to create controlled document "${title}": ${reason ?? 'unknown reason'}`) + } } private async createChangeControl ( diff --git a/plugins/controlled-documents-resources/src/components/create-doc/steps/InfoStep.svelte b/plugins/controlled-documents-resources/src/components/create-doc/steps/InfoStep.svelte index 49346e40f9..8b3bdb1164 100644 --- a/plugins/controlled-documents-resources/src/components/create-doc/steps/InfoStep.svelte +++ b/plugins/controlled-documents-resources/src/components/create-doc/steps/InfoStep.svelte @@ -1,5 +1,6 @@