import type {
|
FillDataResponse,
|
FillFieldData,
|
FillFieldType,
|
RepeatAppendForm,
|
RepeatDeleteForm,
|
RepeatGroup,
|
RepeatRestoreForm,
|
RepeatRow,
|
SourceSyncCommand,
|
SourceSyncDifference,
|
SourceSyncPreview,
|
SourceSynchronized,
|
StepFillStructure,
|
} from '../domain/types';
|
|
import { parseTag } from '../domain/tags';
|
|
const FIELD_TYPES = new Set<FillFieldType>(['text', 'multiline', 'select', 'checkbox', 'date']);
|
|
export class FillDataError extends Error {
|
constructor(message: string, public readonly status?: number) {
|
super(message);
|
this.name = 'FillDataError';
|
}
|
}
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
}
|
|
export function parseFillData(raw: unknown): FillDataResponse {
|
if (!isRecord(raw) || !isRecord(raw.fields)) throw new Error('填写数据格式无效');
|
const fields: Record<string, FillFieldData> = {};
|
const checkboxGroupRules = new Map<string, string>();
|
for (const [tag, value] of Object.entries(raw.fields)) {
|
if (!tag || !isRecord(value) || !FIELD_TYPES.has(value.type as FillFieldType)) throw new Error('填写数据格式无效');
|
if (value.required !== undefined && typeof value.required !== 'boolean') throw new Error('填写数据格式无效');
|
if (value.readOnly !== undefined && typeof value.readOnly !== 'boolean') throw new Error('填写数据格式无效');
|
if (value.valueSource !== undefined && typeof value.valueSource !== 'string') throw new Error('填写数据格式无效');
|
if (value.formulaStatus !== undefined && !['ERROR', 'MISSING_INPUT', 'SUCCESS'].includes(String(value.formulaStatus))) {
|
throw new Error('填写数据格式无效');
|
}
|
if (value.formulaMessage !== undefined && typeof value.formulaMessage !== 'string') throw new Error('填写数据格式无效');
|
const type = value.type as FillFieldType;
|
const parsedTag = parseTag(tag);
|
const hasCheckboxGroup = value.checkboxGroupCode !== undefined;
|
const hasCheckboxGroupMin = value.checkboxGroupMinSelected !== undefined;
|
const hasCheckboxGroupMax = value.checkboxGroupMaxSelected !== undefined;
|
if (hasCheckboxGroup && (!validIdentity(value.checkboxGroupCode)
|
|| type !== 'checkbox' || value.readOnly === true || parsedTag?.type === 'reference')) {
|
throw new Error('填写数据格式无效');
|
}
|
if (!hasCheckboxGroup && (hasCheckboxGroupMin || hasCheckboxGroupMax)) throw new Error('填写数据格式无效');
|
if (hasCheckboxGroup && hasCheckboxGroupMin !== hasCheckboxGroupMax) throw new Error('填写数据格式无效');
|
const checkboxGroupMinSelected = hasCheckboxGroupMin ? value.checkboxGroupMinSelected : 0;
|
const checkboxGroupMaxSelected = hasCheckboxGroupMax ? value.checkboxGroupMaxSelected : 1;
|
if (hasCheckboxGroup && (!Number.isInteger(checkboxGroupMinSelected)
|
|| Number(checkboxGroupMinSelected) < 0
|
|| (checkboxGroupMaxSelected !== null
|
&& (!Number.isInteger(checkboxGroupMaxSelected)
|
|| Number(checkboxGroupMaxSelected) < 1
|
|| Number(checkboxGroupMinSelected) > Number(checkboxGroupMaxSelected))))) {
|
throw new Error('填写数据格式无效');
|
}
|
if (hasCheckboxGroup) {
|
const groupCode = value.checkboxGroupCode as string;
|
const ruleIdentity = `${checkboxGroupMinSelected}:${checkboxGroupMaxSelected === null ? '*' : checkboxGroupMaxSelected}`;
|
if (checkboxGroupRules.has(groupCode) && checkboxGroupRules.get(groupCode) !== ruleIdentity) {
|
throw new Error('填写数据格式无效');
|
}
|
checkboxGroupRules.set(groupCode, ruleIdentity);
|
}
|
const metadata = {
|
...(hasCheckboxGroup
|
? {
|
checkboxGroupCode: value.checkboxGroupCode as string,
|
checkboxGroupMaxSelected: checkboxGroupMaxSelected as null | number,
|
checkboxGroupMinSelected: checkboxGroupMinSelected as number,
|
}
|
: {}),
|
...(typeof value.formulaMessage === 'string' ? { formulaMessage: value.formulaMessage } : {}),
|
...(typeof value.formulaStatus === 'string'
|
? { formulaStatus: value.formulaStatus as FillFieldData['formulaStatus'] }
|
: {}),
|
...(value.readOnly === true ? { readOnly: true } : {}),
|
required: value.required === true,
|
...(typeof value.valueSource === 'string' ? { valueSource: value.valueSource } : {}),
|
};
|
if (type === 'checkbox') {
|
if (value.value !== null && typeof value.value !== 'boolean') throw new Error('填写数据格式无效');
|
fields[tag] = { ...metadata, type, value: value.value ?? false };
|
} else {
|
if (value.value !== null && typeof value.value !== 'string') throw new Error('填写数据格式无效');
|
fields[tag] = { ...metadata, type, value: value.value as null | string };
|
}
|
}
|
const repeatFieldGroups = new Map<string, string>();
|
for (const [tag, field] of Object.entries(fields)) {
|
const parsed = parseTag(tag);
|
if (parsed?.type !== 'repeat') continue;
|
const key = `${parsed.groupCode}\n${parsed.fieldCode}`;
|
const ruleIdentity = field.checkboxGroupCode
|
? `${field.checkboxGroupCode}:${field.checkboxGroupMinSelected}:${field.checkboxGroupMaxSelected === null ? '*' : field.checkboxGroupMaxSelected}`
|
: '';
|
if (repeatFieldGroups.has(key)
|
&& repeatFieldGroups.get(key) !== ruleIdentity) throw new Error('填写数据格式无效');
|
repeatFieldGroups.set(key, ruleIdentity);
|
}
|
const repeatGroups = raw.repeatGroups === undefined ? [] : parseRepeatGroups(raw.repeatGroups);
|
const stepStructure = raw.stepStructure === undefined ? undefined : parseStepStructure(raw.stepStructure);
|
const pendingSourceSync = raw.pendingSourceSync === undefined || raw.pendingSourceSync === null
|
? undefined
|
: parseSourceSynchronized(raw.pendingSourceSync);
|
return {
|
fields,
|
repeatGroups,
|
...(pendingSourceSync ? { pendingSourceSync } : {}),
|
...(stepStructure ? { stepStructure } : {}),
|
};
|
}
|
|
function validIdentity(value: unknown): value is string {
|
return typeof value === 'string' && /^[\w-]{1,80}$/u.test(value);
|
}
|
|
function parseRepeatRow(value: unknown, expectedGroup?: string): RepeatRow {
|
if (!isRecord(value) || !validIdentity(value.groupCode) || !validIdentity(value.rowId)
|
|| value.rowId === 'prototype' || value.groupCode !== (expectedGroup ?? value.groupCode)
|
|| !Number.isInteger(value.rowOrder) || Number(value.rowOrder) < 1
|
|| !['ACTIVE', 'DELETE_PREPARED', 'PREPARED'].includes(String(value.status))) {
|
throw new Error('填写数据格式无效');
|
}
|
if (value.deleteOperationCode !== undefined && value.deleteOperationCode !== null
|
&& typeof value.deleteOperationCode !== 'string') throw new Error('填写数据格式无效');
|
if (value.deleteReason !== undefined && value.deleteReason !== null
|
&& typeof value.deleteReason !== 'string') throw new Error('填写数据格式无效');
|
if (value.status === 'DELETE_PREPARED'
|
&& (!value.deleteOperationCode || !value.deleteReason)) throw new Error('填写数据格式无效');
|
return value as unknown as RepeatRow;
|
}
|
|
function parseRepeatGroups(value: unknown): RepeatGroup[] {
|
if (!Array.isArray(value)) throw new Error('填写数据格式无效');
|
const groups = new Set<string>();
|
return value.map((item) => {
|
if (!isRecord(item) || !validIdentity(item.groupCode) || groups.has(item.groupCode)
|
|| !Number.isInteger(item.minRows) || !Number.isInteger(item.maxRows)
|
|| !Number.isInteger(item.activeRowCount)
|
|| Number(item.minRows) < 0 || Number(item.maxRows) < Number(item.minRows)
|
|| Number(item.activeRowCount) < 0 || !Array.isArray(item.rows)) {
|
throw new Error('填写数据格式无效');
|
}
|
groups.add(item.groupCode);
|
const rows = item.rows.map((row) => parseRepeatRow(row, item.groupCode as string));
|
if (rows.length !== item.activeRowCount) throw new Error('填写数据格式无效');
|
const checkboxGroups: Record<string, string> = {};
|
if (item.checkboxGroups !== undefined) {
|
if (!isRecord(item.checkboxGroups)) throw new Error('填写数据格式无效');
|
for (const [fieldCode, checkboxGroupCode] of Object.entries(item.checkboxGroups)) {
|
if (!validIdentity(fieldCode) || !validIdentity(checkboxGroupCode)) {
|
throw new Error('填写数据格式无效');
|
}
|
checkboxGroups[fieldCode] = checkboxGroupCode;
|
}
|
}
|
return { ...item, checkboxGroups, rows } as unknown as RepeatGroup;
|
});
|
}
|
|
function parseStepStructure(value: unknown): StepFillStructure {
|
if (!isRecord(value) || !isRecord(value.configuration)
|
|| typeof value.configuration.singleStep !== 'boolean'
|
|| !Array.isArray(value.configuration.steps)) throw new Error('填写数据格式无效');
|
const stepIds = new Set<string>();
|
const steps = value.configuration.steps.map((step) => {
|
if (!isRecord(step) || !validIdentity(step.id) || stepIds.has(step.id)
|
|| typeof step.name !== 'string' || !step.name.trim()
|
|| !Number.isInteger(step.order) || Number(step.order) < 1) throw new Error('填写数据格式无效');
|
stepIds.add(step.id);
|
return {
|
id: step.id,
|
name: step.name,
|
order: Number(step.order),
|
};
|
});
|
if (steps.length < 1 || steps.length > 20
|
|| (value.configuration.singleStep && steps.length !== 1)) throw new Error('填写数据格式无效');
|
const parseAssignments = (assignments: unknown, repeatGroups: boolean): Record<string, string> => {
|
if (!isRecord(assignments)) throw new Error('填写数据格式无效');
|
const result: Record<string, string> = {};
|
for (const [key, stepId] of Object.entries(assignments)) {
|
if ((!key || (repeatGroups && !validIdentity(key)))
|
|| !validIdentity(stepId) || !stepIds.has(stepId)) throw new Error('填写数据格式无效');
|
result[key] = stepId;
|
}
|
return result;
|
};
|
return {
|
configuration: { singleStep: value.configuration.singleStep, steps },
|
controlSteps: parseAssignments(value.controlSteps, false),
|
repeatGroupSteps: parseAssignments(value.repeatGroupSteps, true),
|
};
|
}
|
|
function messageFor(status: number): string {
|
if (status === 401) return '填写凭证已过期,请重新打开文档';
|
if (status === 403) return '无权读取填写数据';
|
if (status === 404) return '填写数据不存在';
|
return '填写数据加载失败';
|
}
|
|
function parseSourceSyncDifference(value: unknown): SourceSyncDifference {
|
const optionalStrings = [
|
value && typeof value === 'object' ? (value as Record<string, unknown>).sourceFetchedAt : undefined,
|
value && typeof value === 'object' ? (value as Record<string, unknown>).sourceFieldVersionId : undefined,
|
value && typeof value === 'object' ? (value as Record<string, unknown>).sourceRecordId : undefined,
|
value && typeof value === 'object' ? (value as Record<string, unknown>).sourceRevisionId : undefined,
|
value && typeof value === 'object' ? (value as Record<string, unknown>).sourceValueId : undefined,
|
];
|
if (!isRecord(value)
|
|| typeof value.bindingId !== 'string' || !value.bindingId
|
|| typeof value.fieldCode !== 'string' || !value.fieldCode
|
|| typeof value.fieldName !== 'string'
|
|| typeof value.dataType !== 'string' || !value.dataType
|
|| !['CROSS_TEMPLATE', 'LIMS_TASK'].includes(String(value.sourceType))
|
|| (value.sourceProperty !== undefined && value.sourceProperty !== null && typeof value.sourceProperty !== 'string')
|
|| (value.sourceType === 'LIMS_TASK' && (typeof value.sourceProperty !== 'string' || !value.sourceProperty))
|
|| typeof value.changed !== 'boolean'
|
|| optionalStrings.some((item) => item !== undefined && item !== null && typeof item !== 'string')
|
|| (value.previousValue !== undefined && value.previousValue !== null && typeof value.previousValue !== 'string')
|
|| (value.currentValue !== undefined && value.currentValue !== null && typeof value.currentValue !== 'string')) {
|
throw new FillDataError('来源快照同步响应无效');
|
}
|
return value as unknown as SourceSyncDifference;
|
}
|
|
function sourceSyncBase(value: unknown) {
|
if (!isRecord(value)
|
|| typeof value.recordId !== 'string' || !value.recordId
|
|| typeof value.revisionId !== 'string' || !value.revisionId
|
|| !Number.isInteger(value.recordVersion) || Number(value.recordVersion) < 0
|
|| !Number.isInteger(value.changedCount) || Number(value.changedCount) < 0
|
|| !Array.isArray(value.differences)) {
|
throw new FillDataError('来源快照同步响应无效');
|
}
|
return {
|
changedCount: Number(value.changedCount),
|
differences: value.differences.map(parseSourceSyncDifference),
|
recordId: value.recordId,
|
recordVersion: Number(value.recordVersion),
|
revisionId: value.revisionId,
|
};
|
}
|
|
function parseSourceSyncPreview(value: unknown): SourceSyncPreview {
|
const base = sourceSyncBase(value);
|
if (!isRecord(value) || typeof value.previewToken !== 'string' || !value.previewToken) {
|
throw new FillDataError('来源快照同步响应无效');
|
}
|
return { ...base, previewToken: value.previewToken };
|
}
|
|
function parseSourceSynchronized(value: unknown): SourceSynchronized {
|
const base = sourceSyncBase(value);
|
if (!isRecord(value) || typeof value.operationCode !== 'string' || !value.operationCode
|
|| !Number.isInteger(value.recalculatedCount) || Number(value.recalculatedCount) < 0
|
|| !Array.isArray(value.changedFieldCodes)
|
|| value.changedFieldCodes.some((fieldCode) => typeof fieldCode !== 'string' || !fieldCode)) {
|
throw new FillDataError('来源快照同步响应无效');
|
}
|
return {
|
...base,
|
changedFieldCodes: [...new Set(value.changedFieldCodes as string[])],
|
operationCode: value.operationCode,
|
recalculatedCount: Number(value.recalculatedCount),
|
};
|
}
|
|
async function sourceSyncRequest(
|
context: { apiBaseUrl: string; ticket: string },
|
path: string,
|
body?: SourceSyncCommand,
|
): Promise<unknown> {
|
let response: Response;
|
try {
|
response = await fetch(`${context.apiBaseUrl}/api/eln/onlyoffice/recordFill/sourceSync${path}`, {
|
method: 'POST',
|
headers: {
|
Authorization: `Ticket ${context.ticket}`,
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
},
|
...(body ? { body: JSON.stringify(body) } : {}),
|
});
|
} catch {
|
throw new FillDataError('来源快照同步失败');
|
}
|
if (!response.ok) throw new FillDataError(
|
response.status === 401 ? '填写凭证已过期,请重新打开文档' : '来源快照同步失败',
|
response.status,
|
);
|
try { return await response.json(); } catch { throw new FillDataError('来源快照同步响应无效'); }
|
}
|
|
export async function previewSourceSync(
|
context: { apiBaseUrl: string; ticket: string },
|
): Promise<SourceSyncPreview> {
|
return parseSourceSyncPreview(await sourceSyncRequest(context, '/preview'));
|
}
|
|
export async function synchronizeSource(
|
context: { apiBaseUrl: string; ticket: string }, command: SourceSyncCommand,
|
): Promise<SourceSynchronized> {
|
return parseSourceSynchronized(await sourceSyncRequest(context, '', command));
|
}
|
|
export async function fetchFillData(context: { apiBaseUrl: string; ticket: string }): Promise<FillDataResponse> {
|
let response: Response;
|
try {
|
response = await fetch(`${context.apiBaseUrl}/api/eln/onlyoffice/recordFill/data`, {
|
headers: { Authorization: `Ticket ${context.ticket}` },
|
});
|
} catch {
|
throw new FillDataError('填写数据加载失败');
|
}
|
if (!response.ok) throw new FillDataError(messageFor(response.status), response.status);
|
try {
|
return parseFillData(await response.json());
|
} catch (error) {
|
if (error instanceof FillDataError) throw error;
|
throw new FillDataError('填写数据格式无效');
|
}
|
}
|
|
export async function appendRepeatRow(
|
context: { apiBaseUrl: string; ticket: string },
|
form: RepeatAppendForm,
|
): Promise<RepeatRow> {
|
let response: Response;
|
try {
|
response = await fetch(`${context.apiBaseUrl}/api/eln/onlyoffice/recordFill/repeatRows`, {
|
method: 'POST',
|
headers: { Authorization: `Ticket ${context.ticket}`, 'Content-Type': 'application/json' },
|
body: JSON.stringify(form),
|
});
|
} catch {
|
throw new FillDataError('新增重复行失败');
|
}
|
if (!response.ok) throw new FillDataError(messageFor(response.status), response.status);
|
let value: unknown;
|
try { value = await response.json(); } catch { throw new FillDataError('新增重复行响应无效'); }
|
if (!isRecord(value) || typeof value.groupCode !== 'string' || typeof value.rowId !== 'string'
|
|| typeof value.rowOrder !== 'number' || !['ACTIVE', 'PREPARED'].includes(String(value.status))) {
|
throw new FillDataError('新增重复行响应无效');
|
}
|
return value as unknown as RepeatRow;
|
}
|
|
async function changeRepeatRow(
|
context: { apiBaseUrl: string; ticket: string },
|
action: 'delete' | 'restore',
|
form: RepeatDeleteForm | RepeatRestoreForm,
|
): Promise<RepeatRow> {
|
let response: Response;
|
try {
|
response = await fetch(`${context.apiBaseUrl}/api/eln/onlyoffice/recordFill/repeatRows/${action}`, {
|
method: 'POST',
|
headers: { Authorization: `Ticket ${context.ticket}`, 'Content-Type': 'application/json' },
|
body: JSON.stringify(form),
|
});
|
} catch {
|
throw new FillDataError(action === 'delete' ? '删除重复行失败' : '恢复重复行失败');
|
}
|
if (!response.ok) throw new FillDataError(messageFor(response.status), response.status);
|
let value: unknown;
|
try { value = await response.json(); } catch { throw new FillDataError('重复行响应无效'); }
|
try { return parseRepeatRow(value); } catch { throw new FillDataError('重复行响应无效'); }
|
}
|
|
export function deleteRepeatRow(
|
context: { apiBaseUrl: string; ticket: string }, form: RepeatDeleteForm,
|
): Promise<RepeatRow> {
|
return changeRepeatRow(context, 'delete', form);
|
}
|
|
export function restoreRepeatRow(
|
context: { apiBaseUrl: string; ticket: string }, form: RepeatRestoreForm,
|
): Promise<RepeatRow> {
|
return changeRepeatRow(context, 'restore', form);
|
}
|