This commit is contained in:
phaichayon
2026-06-16 10:57:41 +07:00
parent dff22d75b5
commit 357414c247
33 changed files with 9281 additions and 26 deletions

View File

@@ -0,0 +1,563 @@
import { and, asc, eq, isNull } from 'drizzle-orm';
import {
crmDocumentTemplateMappings,
crmDocumentTemplateTableColumns,
crmDocumentTemplateVersions,
crmDocumentTemplates
} from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import type {
DocumentTemplateDetail,
DocumentTemplateFilters,
DocumentTemplateListItem,
DocumentTemplateMappingRecord,
DocumentTemplateMappingWithColumns,
DocumentTemplateMutationPayload,
DocumentTemplateRecord,
DocumentTemplateTableColumnRecord,
DocumentTemplateVersionDetail,
DocumentTemplateVersionMutationPayload,
DocumentTemplateVersionRecord,
ResolveTemplateParams,
ResolvedDocumentTemplate
} from '../types';
function mapTemplateRecord(row: typeof crmDocumentTemplates.$inferSelect): DocumentTemplateRecord {
return {
id: row.id,
organizationId: row.organizationId,
documentType: row.documentType,
productType: row.productType,
fileType: row.fileType,
templateName: row.templateName,
description: row.description,
isDefault: row.isDefault,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null,
createdBy: row.createdBy,
updatedBy: row.updatedBy
};
}
function mapVersionRecord(
row: typeof crmDocumentTemplateVersions.$inferSelect
): DocumentTemplateVersionRecord {
return {
id: row.id,
organizationId: row.organizationId,
templateId: row.templateId,
version: row.version,
filePath: row.filePath,
schemaJson: row.schemaJson,
previewImageUrl: row.previewImageUrl,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null,
createdBy: row.createdBy
};
}
function mapMappingRecord(
row: typeof crmDocumentTemplateMappings.$inferSelect
): DocumentTemplateMappingRecord {
return {
id: row.id,
organizationId: row.organizationId,
templateVersionId: row.templateVersionId,
placeholderKey: row.placeholderKey,
sourcePath: row.sourcePath,
dataType: row.dataType as DocumentTemplateMappingRecord['dataType'],
sheetName: row.sheetName,
defaultValue: row.defaultValue,
formatMask: row.formatMask,
sortOrder: row.sortOrder,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
};
}
function mapTableColumnRecord(
row: typeof crmDocumentTemplateTableColumns.$inferSelect
): DocumentTemplateTableColumnRecord {
return {
id: row.id,
organizationId: row.organizationId,
mappingId: row.mappingId,
columnName: row.columnName,
sourceField: row.sourceField,
columnLetter: row.columnLetter,
sortOrder: row.sortOrder,
formatMask: row.formatMask,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
};
}
async function assertTemplate(id: string, organizationId: string) {
const [template] = await db
.select()
.from(crmDocumentTemplates)
.where(
and(
eq(crmDocumentTemplates.id, id),
eq(crmDocumentTemplates.organizationId, organizationId),
isNull(crmDocumentTemplates.deletedAt)
)
)
.limit(1);
if (!template) {
throw new AuthError('Document template not found', 404);
}
return template;
}
async function listVersionsByTemplateIds(templateIds: string[], organizationId: string) {
if (!templateIds.length) {
return [];
}
return db
.select()
.from(crmDocumentTemplateVersions)
.where(
and(
eq(crmDocumentTemplateVersions.organizationId, organizationId),
isNull(crmDocumentTemplateVersions.deletedAt)
)
)
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
}
export async function resolveTemplateMappings(
templateVersionId: string,
organizationId: string
): Promise<DocumentTemplateMappingWithColumns[]> {
const mappings = await db
.select()
.from(crmDocumentTemplateMappings)
.where(
and(
eq(crmDocumentTemplateMappings.templateVersionId, templateVersionId),
eq(crmDocumentTemplateMappings.organizationId, organizationId),
isNull(crmDocumentTemplateMappings.deletedAt)
)
)
.orderBy(asc(crmDocumentTemplateMappings.sortOrder), asc(crmDocumentTemplateMappings.createdAt));
const columns = await db
.select()
.from(crmDocumentTemplateTableColumns)
.where(
and(
eq(crmDocumentTemplateTableColumns.organizationId, organizationId),
isNull(crmDocumentTemplateTableColumns.deletedAt)
)
)
.orderBy(
asc(crmDocumentTemplateTableColumns.sortOrder),
asc(crmDocumentTemplateTableColumns.createdAt)
);
const columnMap = new Map<string, DocumentTemplateTableColumnRecord[]>();
for (const column of columns.map(mapTableColumnRecord)) {
const items = columnMap.get(column.mappingId) ?? [];
items.push(column);
columnMap.set(column.mappingId, items);
}
return mappings.map((mapping) => ({
...mapMappingRecord(mapping),
columns: columnMap.get(mapping.id) ?? []
}));
}
export async function listDocumentTemplates(
organizationId: string,
filters: DocumentTemplateFilters = {}
): Promise<DocumentTemplateListItem[]> {
const templates = await db
.select()
.from(crmDocumentTemplates)
.where(
and(
eq(crmDocumentTemplates.organizationId, organizationId),
isNull(crmDocumentTemplates.deletedAt),
...(filters.documentType ? [eq(crmDocumentTemplates.documentType, filters.documentType)] : []),
...(filters.fileType ? [eq(crmDocumentTemplates.fileType, filters.fileType)] : []),
...(filters.isActive === 'true' ? [eq(crmDocumentTemplates.isActive, true)] : []),
...(filters.isActive === 'false' ? [eq(crmDocumentTemplates.isActive, false)] : [])
)
)
.orderBy(asc(crmDocumentTemplates.templateName));
const versions = await db
.select()
.from(crmDocumentTemplateVersions)
.where(
and(
eq(crmDocumentTemplateVersions.organizationId, organizationId),
isNull(crmDocumentTemplateVersions.deletedAt)
)
)
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
const mappings = await db
.select()
.from(crmDocumentTemplateMappings)
.where(
and(
eq(crmDocumentTemplateMappings.organizationId, organizationId),
isNull(crmDocumentTemplateMappings.deletedAt)
)
);
return templates.map((template) => {
const templateVersions = versions.filter((item) => item.templateId === template.id);
const latestVersion = templateVersions.at(-1) ?? null;
const templateVersionIds = new Set(templateVersions.map((item) => item.id));
const mappingCount = mappings.filter((item) => templateVersionIds.has(item.templateVersionId)).length;
return {
...mapTemplateRecord(template),
latestVersion: latestVersion?.version ?? null,
versionCount: templateVersions.length,
mappingCount
};
});
}
export async function getDocumentTemplate(
id: string,
organizationId: string
): Promise<DocumentTemplateDetail> {
const template = await assertTemplate(id, organizationId);
const versions = await db
.select()
.from(crmDocumentTemplateVersions)
.where(
and(
eq(crmDocumentTemplateVersions.templateId, id),
eq(crmDocumentTemplateVersions.organizationId, organizationId),
isNull(crmDocumentTemplateVersions.deletedAt)
)
)
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
const versionsWithMappings: DocumentTemplateVersionDetail[] = [];
let mappingCount = 0;
for (const version of versions) {
const mappings = await resolveTemplateMappings(version.id, organizationId);
mappingCount += mappings.length;
versionsWithMappings.push({
...mapVersionRecord(version),
mappings
});
}
return {
...mapTemplateRecord(template),
versions: versionsWithMappings,
mappingCount
};
}
export async function listDocumentTemplateVersions(id: string, organizationId: string) {
await assertTemplate(id, organizationId);
const versions = await db
.select()
.from(crmDocumentTemplateVersions)
.where(
and(
eq(crmDocumentTemplateVersions.templateId, id),
eq(crmDocumentTemplateVersions.organizationId, organizationId),
isNull(crmDocumentTemplateVersions.deletedAt)
)
)
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
return Promise.all(
versions.map(async (version) => ({
...mapVersionRecord(version),
mappings: await resolveTemplateMappings(version.id, organizationId)
}))
);
}
export async function createDocumentTemplate(
organizationId: string,
userId: string,
payload: DocumentTemplateMutationPayload
) {
const [created] = await db
.insert(crmDocumentTemplates)
.values({
id: crypto.randomUUID(),
organizationId,
documentType: payload.documentType,
productType: payload.productType,
fileType: payload.fileType,
templateName: payload.templateName.trim(),
description: payload.description?.trim() || null,
isDefault: payload.isDefault ?? false,
isActive: payload.isActive ?? true,
createdBy: userId,
updatedBy: userId
})
.returning();
if (created.isDefault) {
await normalizeDefaultTemplate(created.id, organizationId, created.documentType, created.fileType);
}
return created;
}
export async function updateDocumentTemplate(
id: string,
organizationId: string,
userId: string,
payload: Partial<DocumentTemplateMutationPayload>
) {
const template = await assertTemplate(id, organizationId);
const [updated] = await db
.update(crmDocumentTemplates)
.set({
documentType: payload.documentType ?? template.documentType,
productType: payload.productType ?? template.productType,
fileType: payload.fileType ?? template.fileType,
templateName: payload.templateName?.trim() ?? template.templateName,
description: payload.description === undefined ? template.description : payload.description?.trim() || null,
isDefault: payload.isDefault ?? template.isDefault,
isActive: payload.isActive ?? template.isActive,
updatedAt: new Date(),
updatedBy: userId
})
.where(eq(crmDocumentTemplates.id, id))
.returning();
if (updated.isDefault) {
await normalizeDefaultTemplate(updated.id, organizationId, updated.documentType, updated.fileType);
}
return updated;
}
async function normalizeDefaultTemplate(
templateId: string,
organizationId: string,
documentType: string,
fileType: string
) {
await db
.update(crmDocumentTemplates)
.set({
isDefault: false,
updatedAt: new Date()
})
.where(
and(
eq(crmDocumentTemplates.organizationId, organizationId),
eq(crmDocumentTemplates.documentType, documentType),
eq(crmDocumentTemplates.fileType, fileType),
isNull(crmDocumentTemplates.deletedAt)
)
);
await db
.update(crmDocumentTemplates)
.set({
isDefault: true,
updatedAt: new Date()
})
.where(eq(crmDocumentTemplates.id, templateId));
}
export async function softDeleteDocumentTemplate(id: string, organizationId: string, userId: string) {
await assertTemplate(id, organizationId);
const [updated] = await db
.update(crmDocumentTemplates)
.set({
deletedAt: new Date(),
updatedAt: new Date(),
updatedBy: userId
})
.where(eq(crmDocumentTemplates.id, id))
.returning();
return updated;
}
export async function createDocumentTemplateVersion(
templateId: string,
organizationId: string,
userId: string,
payload: DocumentTemplateVersionMutationPayload
) {
await assertTemplate(templateId, organizationId);
const [created] = await db
.insert(crmDocumentTemplateVersions)
.values({
id: crypto.randomUUID(),
organizationId,
templateId,
version: payload.version.trim(),
filePath: payload.filePath?.trim() || null,
schemaJson: payload.schemaJson,
previewImageUrl: payload.previewImageUrl?.trim() || null,
isActive: payload.isActive ?? true,
createdBy: userId
})
.returning();
return created;
}
export async function resolveTemplateForDocument(
organizationId: string,
params: ResolveTemplateParams
): Promise<ResolvedDocumentTemplate> {
const templates = await db
.select()
.from(crmDocumentTemplates)
.where(
and(
eq(crmDocumentTemplates.organizationId, organizationId),
eq(crmDocumentTemplates.documentType, params.documentType),
eq(crmDocumentTemplates.fileType, params.fileType),
isNull(crmDocumentTemplates.deletedAt),
eq(crmDocumentTemplates.isActive, true)
)
)
.orderBy(asc(crmDocumentTemplates.createdAt));
const exactTemplate =
templates.find(
(template) => template.productType === (params.productType ?? 'default') && template.isDefault
) ??
templates.find((template) => template.productType === (params.productType ?? 'default')) ??
templates.find((template) => template.productType === 'default' && template.isDefault) ??
templates.find((template) => template.productType === 'default') ??
templates.find((template) => template.isDefault) ??
templates[0];
if (!exactTemplate) {
throw new AuthError('No active document template found', 404);
}
const versions = await db
.select()
.from(crmDocumentTemplateVersions)
.where(
and(
eq(crmDocumentTemplateVersions.organizationId, organizationId),
eq(crmDocumentTemplateVersions.templateId, exactTemplate.id),
eq(crmDocumentTemplateVersions.isActive, true),
isNull(crmDocumentTemplateVersions.deletedAt)
)
)
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
const version = versions.at(-1);
if (!version) {
throw new AuthError('No active template version found', 404);
}
return {
template: mapTemplateRecord(exactTemplate),
version: mapVersionRecord(version)
};
}
function getValueByPath(source: unknown, path: string): unknown {
return path.split('.').reduce<unknown>((current, segment) => {
if (current === null || current === undefined) {
return undefined;
}
if (Array.isArray(current) && /^\d+$/.test(segment)) {
return current[Number(segment)];
}
if (typeof current === 'object' && current !== null && segment in current) {
return (current as Record<string, unknown>)[segment];
}
return undefined;
}, source);
}
function applyFormatMask(value: unknown, formatMask?: string | null) {
if (value === null || value === undefined) {
return value;
}
if (formatMask === 'currency' && typeof value === 'number') {
return value.toLocaleString();
}
if (formatMask === 'date' && typeof value === 'string') {
return new Date(value).toLocaleDateString();
}
return value;
}
export function mapDocumentDataToTemplateInput(
documentData: Record<string, unknown>,
mappings: DocumentTemplateMappingWithColumns[]
) {
const result: Record<string, unknown> = {};
for (const mapping of mappings) {
const rawValue = getValueByPath(documentData, mapping.sourcePath);
if (mapping.dataType === 'table') {
const rows = Array.isArray(rawValue) ? rawValue : [];
result[mapping.placeholderKey] = rows.map((row) => {
const rowRecord = row as Record<string, unknown>;
return mapping.columns.reduce<Record<string, unknown>>((accumulator, column) => {
accumulator[column.columnName] = applyFormatMask(
rowRecord[column.sourceField],
column.formatMask
);
return accumulator;
}, {});
});
continue;
}
if (mapping.dataType === 'multiline') {
if (Array.isArray(rawValue)) {
const normalized = rawValue
.map((item) => {
if (typeof item === 'string') {
return item;
}
if (item && typeof item === 'object' && 'items' in item) {
return ((item as { items?: string[] }).items ?? []).join('\n');
}
return String(item ?? '');
})
.filter(Boolean)
.join('\n\n');
result[mapping.placeholderKey] = normalized || mapping.defaultValue || '';
continue;
}
result[mapping.placeholderKey] =
typeof rawValue === 'string' ? rawValue : mapping.defaultValue || '';
continue;
}
result[mapping.placeholderKey] =
applyFormatMask(rawValue, mapping.formatMask) ?? mapping.defaultValue ?? '';
}
return result;
}