일정 검증 규칙과 실제 중복 검사
검증 규칙(Validation rule)은 TypeScript 함수로 일정 객체의 상태를 평가합니다. 최초 로드 시 현재 온톨로지 상태를 검사하고, 일정을 수정할 때마다 다시 평가하여 위반 상태를 화면에 보여 줍니다.
가상의 No Operator Overlaps 규칙은 같은 담당자에게 시간이 겹치는 일을 배정했는지 확인합니다. 아래 화면은 Brad Evans와 Ashley Brown의 중복 배정 상태를 조사하는 예시입니다.
시나리오에서의 평가 순서
시나리오를 사용하면 먼저 saveHandler 액션으로 제안 변경을 적용한 뒤 갱신한 시나리오에 검증 함수를 호출합니다. 사용자가 다시 수정할 때마다 이 순서를 반복합니다. 마지막에 Submit changes로 시나리오에 적용한 액션을 온톨로지에 반영합니다.
화면의 규칙 표시와 저장 액션의 제출 조건 및 동시 편집 정합성은 별개입니다. 운영 제약을 강제해야 한다면 액션의 조건과 일관성도 구성해야 합니다.
위젯에서 규칙 연결
- 해당 일정 계층의
Rules를 열고Add new item을 선택합니다. - 사용자에게 보여 줄 설명을
Rule Name에 입력합니다. Constraint Type에서 HARD 또는 SOFT를 선택합니다. HARD는 빨간 원과 느낌표, SOFT는 주황 삼각형과 느낌표로 표시합니다.Rule Function을 선택합니다.- 함수 입력 이름을 정확히
scheduleObjectPrimaryKeys로 정의합니다. 설정의 해당 값은 비워 둘 수 있으며 위젯이 실행 시 기본 키 목록을 자동 입력합니다.
Background와 Event 스타일의 Gantt 계층은 Rules를 지원하지 않습니다.
결과 계약
각 입력 기본 키에 결과를 대응시킵니다. result=true는 통과, false는 위반, undefined는 그 일정에 적용하지 않는 규칙입니다. details를 생략하면 기본으로 위젯에 설정한 규칙 이름을 표시합니다. 설명과 관련 일정 블록 ID를 추가하면 위반 이유를 가리킬 수 있습니다.
TSv1 담당자 중복 검사 예제
이 예제는 한 담당자에게 동시에 두 작업을 배정하지 않고 각 작업의 기간이 양수여야 한다는 업무 규칙을 선택합니다. Gantt의 Allocate 방식은 겹침을 허용하므로 이를 Palantir의 공통 기본 제약으로 해석하지 않아야 합니다. 가상의 TaskOrSchedule 유형을 프로젝트로 가져오고 다음 속성을 구성합니다.
scheduleId: String 기본 키 속성의 API 이름operatorId: 담당자 ID인 StringstartTime,endTime: Timestamp
검색에 사용하는 네 속성에는 Searchable 렌더링 힌트가 필요합니다. 함수 실행 주체는 검사할 담당자의 관련 일정 전체를 조회할 수 있어야 합니다. 숨겨진 일정까지 검증한 결과로 해석하면 안 됩니다.
배정이나 시간이 없는 일정은 적용 대상이 아닙니다. 종료가 시작보다 빠르거나 같으면 위반입니다. 유효한 기간은 반열린 구간 [시작, 종료)로 비교하여 앞 일정의 종료와 다음 일정의 시작이 같으면 겹치지 않는 것으로 정의합니다. 입력 목록 밖의 일정도 같은 담당자와 겹칠 수 있으므로 함께 조회합니다.
TSv1 함수 저장소의 소스 디렉터리에 schedulingValidation.ts를 작성합니다.
import { Function, Timestamp, UserFacingError } from "@foundry/functions-api";
import { Objects, TaskOrSchedule } from "@foundry/ontology-api";
interface IRuleResultDetails {
description: string;
relatedPuckIds: string[];
}
interface IRuleResult {
result: boolean | undefined;
scheduleObjectPrimaryKey: string;
details?: IRuleResultDetails[];
}
interface Interval {
key: string;
start: number;
end: number;
}
export class SchedulingValidationFunctions {
@Function()
public async evaluateIfTaskOrScheduleIsValid(
scheduleObjectPrimaryKeys: string[]
): Promise<IRuleResult[]> {
if (scheduleObjectPrimaryKeys.length === 0) {
return [];
}
const requested = await Objects.search().taskOrSchedule()
.filter(task => task.scheduleId.exactMatch(...scheduleObjectPrimaryKeys))
.allAsync();
const byKey = new Map<string, TaskOrSchedule>();
for (const task of requested) {
byKey.set(task.scheduleId, task);
}
const results = new Map<string, IRuleResult>();
const operatorIds = new Set<string>();
let lower: Timestamp | undefined;
let upper: Timestamp | undefined;
for (const key of scheduleObjectPrimaryKeys) {
const task = byKey.get(key);
if (task === undefined) {
throw new UserFacingError(`일정을 조회할 수 없습니다: ${key}`);
}
const start = task.startTime;
const end = task.endTime;
const operatorId = task.operatorId;
if (start === undefined || end === undefined || operatorId === undefined) {
results.set(key, {
result: undefined,
scheduleObjectPrimaryKey: key
});
continue;
}
if (end.getTime() <= start.getTime()) {
results.set(key, {
result: false,
scheduleObjectPrimaryKey: key,
details: [{
description: "종료 시각은 시작 시각보다 늦어야 합니다.",
relatedPuckIds: [key]
}]
});
continue;
}
results.set(key, { result: true, scheduleObjectPrimaryKey: key });
operatorIds.add(operatorId);
if (lower === undefined || start.getTime() < lower.getTime()) lower = start;
if (upper === undefined || end.getTime() > upper.getTime()) upper = end;
}
if (lower === undefined || upper === undefined) {
return scheduleObjectPrimaryKeys.map(key => results.get(key)!);
}
const peers = await Objects.search().taskOrSchedule()
.filter(task => task.operatorId.exactMatch(...operatorIds))
.filter(task => task.startTime.range().lt(upper!))
.filter(task => task.endTime.range().gt(lower!))
.allAsync();
const groups = new Map<string, Interval[]>();
for (const task of peers) {
const operatorId = task.operatorId;
const start = task.startTime?.getTime();
const end = task.endTime?.getTime();
if (operatorId === undefined || start === undefined || end === undefined) {
throw new UserFacingError("관련 일정의 담당자와 시간을 조회할 수 없습니다.");
}
if (end <= start) {
if (!results.has(task.scheduleId)) {
throw new UserFacingError(`관련 일정의 기간이 유효하지 않습니다: ${task.scheduleId}`);
}
continue;
}
const interval: Interval = { key: task.scheduleId, start, end };
const group = groups.get(operatorId);
if (group === undefined) groups.set(operatorId, [interval]);
else group.push(interval);
}
const markOverlap = (key: string, otherKey: string): void => {
const result = results.get(key);
if (result?.result === true) {
result.result = false;
result.details = [{
description: `같은 담당자의 일정 ${otherKey}와 시간이 겹칩니다.`,
relatedPuckIds: [otherKey]
}];
}
};
for (const intervals of groups.values()) {
intervals.sort((left, right) => left.start - right.start);
let furthest: Interval | undefined;
for (const current of intervals) {
if (furthest !== undefined && current.start < furthest.end) {
markOverlap(current.key, furthest.key);
markOverlap(furthest.key, current.key);
}
if (furthest === undefined || current.end > furthest.end) {
furthest = current;
}
}
}
return scheduleObjectPrimaryKeys.map(key => results.get(key)!);
}
}
index.ts에서 클래스를 내보냅니다.
export { SchedulingValidationFunctions } from "./schedulingValidation";
함수는 검증할 기본 키를 받아 갱신된 시나리오의 객체를 조회합니다. 인수는 변경 전후의 객체 쌍이나 아직 적용하지 않은 편집 명령이 아닙니다. 입력 키 목록이 관련 일정 전체라고 가정하지 않고, 필요한 담당자와 시간 범위의 다른 일정도 한 번 더 조회합니다.
비교에는 두 번째 조회의 각 객체를 한 번만 넣습니다. 첫 조회의 객체를 다시 합치거나 이전 위치의 사본을 추가하지 않으므로 수정 중인 객체를 자기 자신과 비교하지 않습니다. 같은 호출의 여러 일정이 이미 시나리오에 반영되어 있으면 서로의 겹침도 검사합니다. 반환 결과는 요청한 키에 대해서만 만들며, 충돌 증거는 입력 목록 밖의 일정일 수도 있습니다.
담당자별 시작 순서와 가장 늦은 종료를 추적하여 중복을 찾고, 위반마다 이유를 설명할 관련 일정 하나를 제공합니다. 모든 충돌 쌍을 열거하지는 않습니다. 아직 시나리오에 적용하지 않은 변경이나 이후 다른 호출 및 사용자가 만든 변경까지 미리 검증하는 함수는 아니므로 최종 저장의 정합성은 액션에서 별도로 다뤄야 합니다.
빈 키 목록은 바로 빈 결과를 반환합니다. TSv1의 빈 exactMatch 목록은 필터를 무시하므로 조회 전에 이 조건을 처리해야 전체 유형을 읽지 않습니다. 없는 키나 관련 데이터 오류는 통과로 처리하지 않고 오류를 냅니다. TSv1 객체 로드의 개수 및 실행 한도도 적용됩니다.
일정의 무간격 순서, 자원의 자격증이나 기술 일치 등은 그 업무 속성을 조회하는 별도 검증 규칙으로 구현할 수 있습니다.