chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,639 @@
|
||||
import path from "node:path";
|
||||
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||
import {
|
||||
DispoImportSourceKind,
|
||||
DispoStagedRecordType,
|
||||
ImportBatchStatus,
|
||||
ResourceType,
|
||||
StagedRecordStatus,
|
||||
} from "@planarchy/db";
|
||||
import {
|
||||
createWeekdayAvailabilityFromFte,
|
||||
normalizeCanonicalResourceIdentity,
|
||||
normalizeDispoChapterToken,
|
||||
} from "@planarchy/shared";
|
||||
|
||||
export type DispoImportDbClient = Pick<
|
||||
PrismaClient,
|
||||
| "client"
|
||||
| "country"
|
||||
| "importBatch"
|
||||
| "managementLevel"
|
||||
| "managementLevelGroup"
|
||||
| "metroCity"
|
||||
| "orgUnit"
|
||||
| "stagedAssignment"
|
||||
| "stagedAvailabilityRule"
|
||||
| "stagedClient"
|
||||
| "stagedProject"
|
||||
| "stagedResource"
|
||||
| "stagedVacation"
|
||||
| "stagedUnresolvedRecord"
|
||||
>;
|
||||
|
||||
export interface DispoReferenceImportInput {
|
||||
importBatchId?: string;
|
||||
notes?: string | null;
|
||||
referenceWorkbookPath: string;
|
||||
}
|
||||
|
||||
export interface DispoChargeabilityImportInput {
|
||||
chargeabilityWorkbookPath: string;
|
||||
excludedResourceExternalIds?: string[];
|
||||
importBatchId?: string;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface DispoRosterImportInput {
|
||||
costWorkbookPath?: string;
|
||||
importBatchId?: string;
|
||||
notes?: string | null;
|
||||
rosterWorkbookPath: string;
|
||||
}
|
||||
|
||||
export interface DispoPlanningImportInput {
|
||||
excludedResourceExternalIds?: string[];
|
||||
importBatchId?: string;
|
||||
notes?: string | null;
|
||||
planningWorkbookPath: string;
|
||||
}
|
||||
|
||||
export interface ParsedCountryReference {
|
||||
sourceRow: number;
|
||||
countryCode: string;
|
||||
name: string;
|
||||
dailyWorkingHours: number;
|
||||
metroCities: string[];
|
||||
scheduleRules?: Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
export interface ParsedOrgUnitReference {
|
||||
sourceRow: number;
|
||||
level: number;
|
||||
name: string;
|
||||
parentName: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface ParsedManagementLevelGroupReference {
|
||||
sourceRow: number;
|
||||
name: string;
|
||||
targetPercentage: number;
|
||||
sortOrder: number;
|
||||
levels: string[];
|
||||
}
|
||||
|
||||
export interface ParsedClientReference {
|
||||
sourceColumn: string;
|
||||
sourceRow: number;
|
||||
clientCode: string | null;
|
||||
name: string;
|
||||
parentClientCode: string | null;
|
||||
parentName: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface ParsedReferenceWorkbook {
|
||||
clients: ParsedClientReference[];
|
||||
countries: ParsedCountryReference[];
|
||||
managementLevelGroups: ParsedManagementLevelGroupReference[];
|
||||
orgUnits: ParsedOrgUnitReference[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ParsedChargeabilityResource {
|
||||
availability: Prisma.InputJsonValue;
|
||||
canonicalExternalId: string;
|
||||
chapter: string | null;
|
||||
chapterCode: string | null;
|
||||
chargeabilityTarget: number | null;
|
||||
clientUnitName: string | null;
|
||||
countryCode: string | null;
|
||||
displayName: string;
|
||||
eid: string;
|
||||
email: string | null;
|
||||
enterpriseId: string;
|
||||
fte: number | null;
|
||||
managementLevelGroupName: string | null;
|
||||
managementLevelName: string | null;
|
||||
metroCityName: string | null;
|
||||
rawResourceType: string | null;
|
||||
resourceType: ResourceType;
|
||||
roleTokens: string[];
|
||||
sourceRow: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ParsedUnresolvedRecord {
|
||||
message: string;
|
||||
normalizedData: Record<string, unknown>;
|
||||
projectKey?: string | null;
|
||||
recordType: DispoStagedRecordType;
|
||||
resolutionHint?: string | null;
|
||||
resourceExternalId?: string | null;
|
||||
sourceColumn?: string | null;
|
||||
sourceRow: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ParsedChargeabilityWorkbook {
|
||||
resources: ParsedChargeabilityResource[];
|
||||
unresolved: ParsedUnresolvedRecord[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ParsedRosterResource {
|
||||
availability: Prisma.InputJsonValue;
|
||||
canonicalExternalId: string;
|
||||
chapter: string | null;
|
||||
chapterCode: string | null;
|
||||
clientUnitName: string | null;
|
||||
countryCode: string | null;
|
||||
dailyWorkingHoursPerFte: number | null;
|
||||
department: string | null;
|
||||
displayName: string;
|
||||
eid: string;
|
||||
email: string | null;
|
||||
enterpriseId: string;
|
||||
firstDayInDispo: Date | null;
|
||||
fte: number | null;
|
||||
lastDayInDispo: Date | null;
|
||||
lcrCents: number | null;
|
||||
mainSkillset: string | null;
|
||||
managementLevelGroupName: string | null;
|
||||
managementLevelName: string | null;
|
||||
metroCityName: string | null;
|
||||
rawResourceType: string | null;
|
||||
resourceHoursPerWeek: number | null;
|
||||
resourceType: ResourceType;
|
||||
rateResolution: "EXACT" | "LEVEL_AVERAGE" | "MISSING";
|
||||
rateResolutionLevel: string | null;
|
||||
roleTokens: string[];
|
||||
sapEmployeeName: string | null;
|
||||
sapOrgUnitLevelFive: string | null;
|
||||
sapOrgUnitLevelSix: string | null;
|
||||
sapOrgUnitLevelSeven: string | null;
|
||||
sourceRow: number;
|
||||
sourceSheet: string;
|
||||
ucrCents: number | null;
|
||||
vacationDaysPerYear: number | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ParsedRosterWorkbook {
|
||||
excludedCanonicalExternalIds: string[];
|
||||
ignoredPseudoDemandRows: number;
|
||||
resources: ParsedRosterResource[];
|
||||
unresolved: ParsedUnresolvedRecord[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ParsedPlanningAssignment {
|
||||
assignmentDate: Date;
|
||||
chapterToken: string | null;
|
||||
hoursPerDay: number;
|
||||
isInternal: boolean;
|
||||
isTbd: boolean;
|
||||
isUnassigned: boolean;
|
||||
percentage: number;
|
||||
projectKey: string | null;
|
||||
rawToken: string;
|
||||
resourceExternalId: string;
|
||||
roleName: string | null;
|
||||
roleToken: string | null;
|
||||
slotFraction: number;
|
||||
sourceColumn: string;
|
||||
sourceRow: number;
|
||||
utilizationCategoryCode: string | null;
|
||||
warnings: string[];
|
||||
winProbability: number | null;
|
||||
}
|
||||
|
||||
export interface ParsedPlanningVacation {
|
||||
endDate: Date;
|
||||
halfDayPart: string | null;
|
||||
holidayName: string | null;
|
||||
isHalfDay: boolean;
|
||||
isPublicHoliday: boolean;
|
||||
note: string | null;
|
||||
rawToken: string;
|
||||
resourceExternalId: string;
|
||||
sourceColumn: string;
|
||||
sourceRow: number;
|
||||
startDate: Date;
|
||||
vacationType: "ANNUAL" | "OTHER" | "PUBLIC_HOLIDAY" | "SICK";
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ParsedPlanningAvailabilityRule {
|
||||
availableHours: number | null;
|
||||
effectiveEndDate: Date | null;
|
||||
effectiveStartDate: Date | null;
|
||||
isResolved: boolean;
|
||||
percentage: number | null;
|
||||
rawToken: string;
|
||||
resourceExternalId: string;
|
||||
ruleType: string;
|
||||
sourceColumn: string;
|
||||
sourceRow: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ParsedPlanningWorkbook {
|
||||
assignments: ParsedPlanningAssignment[];
|
||||
availabilityRules: ParsedPlanningAvailabilityRule[];
|
||||
unresolved: ParsedUnresolvedRecord[];
|
||||
vacations: ParsedPlanningVacation[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
const COUNTRY_REFERENCE_CONFIG = {
|
||||
"Costa Rica": {
|
||||
code: "CR",
|
||||
dailyWorkingHours: 8,
|
||||
},
|
||||
Germany: {
|
||||
code: "DE",
|
||||
dailyWorkingHours: 8,
|
||||
},
|
||||
Hungary: {
|
||||
code: "HU",
|
||||
dailyWorkingHours: 8,
|
||||
},
|
||||
India: {
|
||||
code: "IN",
|
||||
dailyWorkingHours: 9,
|
||||
},
|
||||
Italy: {
|
||||
code: "IT",
|
||||
dailyWorkingHours: 8,
|
||||
},
|
||||
Portugal: {
|
||||
code: "PT",
|
||||
dailyWorkingHours: 8,
|
||||
},
|
||||
Spain: {
|
||||
code: "ES",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: {
|
||||
type: "spain",
|
||||
fridayHours: 6.5,
|
||||
summerPeriod: { from: "07-01", to: "09-15" },
|
||||
summerHours: 6.5,
|
||||
regularHours: 9,
|
||||
},
|
||||
},
|
||||
"United Kingdom": {
|
||||
code: "GB",
|
||||
dailyWorkingHours: 8,
|
||||
},
|
||||
} as const satisfies Record<
|
||||
string,
|
||||
{
|
||||
code: string;
|
||||
dailyWorkingHours: number;
|
||||
scheduleRules?: Prisma.InputJsonValue;
|
||||
}
|
||||
>;
|
||||
|
||||
const CLIENT_CODE_OVERRIDES = {
|
||||
BMW: "BMW",
|
||||
DAIMLER: "DAIMLER",
|
||||
"EXOR-STELLANTIS": "STELLANTIS",
|
||||
VOLKSWAGEN: "VW",
|
||||
"TATA MOTORS GROUP": "JLR",
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
const NULLISH_TOKENS = new Set(["", "-", "0", "(Blank)"]);
|
||||
|
||||
function collapseWhitespace(value: string): string {
|
||||
return value.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
export function normalizeText(value: unknown): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = collapseWhitespace(String(value));
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function normalizeNullableWorkbookValue(value: unknown): string | null {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return NULLISH_TOKENS.has(normalized) ? null : normalized;
|
||||
}
|
||||
|
||||
export function buildFallbackAccentureEmail(canonicalExternalId: string): string {
|
||||
return `${canonicalExternalId}@accenture.com`;
|
||||
}
|
||||
|
||||
export function isPseudoDemandResourceIdentity(value: string | null | undefined): boolean {
|
||||
return typeof value === "string" && value.toLowerCase().startsWith("demand_");
|
||||
}
|
||||
|
||||
export function sanitizeClientName(value: string): string {
|
||||
return collapseWhitespace(value.replace(/\s*-\s*$/, ""));
|
||||
}
|
||||
|
||||
export function getWorkbookFileName(workbookPath: string): string {
|
||||
return path.basename(workbookPath);
|
||||
}
|
||||
|
||||
export function findSectionRow(
|
||||
rows: ReadonlyArray<ReadonlyArray<unknown>>,
|
||||
firstCellValue: string,
|
||||
): number {
|
||||
const normalizedTarget = firstCellValue.toLowerCase();
|
||||
|
||||
for (let index = 0; index < rows.length; index += 1) {
|
||||
const current = normalizeText(rows[index]?.[0]);
|
||||
if (current?.toLowerCase() === normalizedTarget) {
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Section row "${firstCellValue}" not found`);
|
||||
}
|
||||
|
||||
export function getCountryReferenceConfig(countryName: string) {
|
||||
return COUNTRY_REFERENCE_CONFIG[countryName as keyof typeof COUNTRY_REFERENCE_CONFIG] ?? null;
|
||||
}
|
||||
|
||||
export function deriveCountryCodeFromMetroCity(
|
||||
metroCityName: string | null | undefined,
|
||||
): string | null {
|
||||
if (!metroCityName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const [countryName, config] of Object.entries(COUNTRY_REFERENCE_CONFIG)) {
|
||||
if (metroCityName === countryName) {
|
||||
return config.code;
|
||||
}
|
||||
|
||||
const isGermanCity =
|
||||
countryName === "Germany" &&
|
||||
["Bonn", "Frankfurt", "Hamburg", "Munich", "Stuttgart"].includes(metroCityName);
|
||||
const isPortugueseCity = countryName === "Portugal" && metroCityName === "Lisbon";
|
||||
const isUkCity = countryName === "United Kingdom" && metroCityName === "Birmingham";
|
||||
const isCostaRica = countryName === "Costa Rica" && metroCityName === "Costa Rica";
|
||||
|
||||
if (isGermanCity || isPortugueseCity || isUkCity || isCostaRica) {
|
||||
return config.code;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeClientCode(masterClientName: string): string | null {
|
||||
return CLIENT_CODE_OVERRIDES[masterClientName as keyof typeof CLIENT_CODE_OVERRIDES] ?? null;
|
||||
}
|
||||
|
||||
export function ensurePercentageValue(value: number | null): number | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value <= 1 ? Math.round(value * 10000) / 100 : value;
|
||||
}
|
||||
|
||||
export function deriveDisplayNameFromEnterpriseId(enterpriseId: string): string {
|
||||
return enterpriseId
|
||||
.split(".")
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function deriveRoleTokens(...values: Array<string | null | undefined>): string[] {
|
||||
const tokenSet = new Set<string>();
|
||||
const combinedValue = values
|
||||
.map((value) => normalizeText(value))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(" ")
|
||||
.toUpperCase();
|
||||
|
||||
if (combinedValue.includes("2D")) {
|
||||
tokenSet.add("2D");
|
||||
}
|
||||
if (combinedValue.includes("3D")) {
|
||||
tokenSet.add("3D");
|
||||
}
|
||||
if (combinedValue.includes("PROGRAM/DELIVERY MGMT") || combinedValue.includes("PROJECT MANAGEMENT")) {
|
||||
tokenSet.add("PM");
|
||||
}
|
||||
if (combinedValue.includes("ART DIRECTION")) {
|
||||
tokenSet.add("AD");
|
||||
}
|
||||
|
||||
return Array.from(tokenSet);
|
||||
}
|
||||
|
||||
export function deriveNormalizedChapter(
|
||||
rawChapter: string | null,
|
||||
roleTokens: string[],
|
||||
): { chapter: string | null; chapterCode: string | null } {
|
||||
const firstRoleToken = roleTokens[0] ?? null;
|
||||
if (firstRoleToken) {
|
||||
const normalizedChapter = normalizeDispoChapterToken(firstRoleToken);
|
||||
if (normalizedChapter) {
|
||||
return {
|
||||
chapter: normalizedChapter,
|
||||
chapterCode: firstRoleToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
chapter: rawChapter,
|
||||
chapterCode: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapChargeabilityResourceType(rawValue: string | null): {
|
||||
resourceType: ResourceType;
|
||||
warning: string | null;
|
||||
} {
|
||||
if (!rawValue) {
|
||||
return {
|
||||
resourceType: ResourceType.EMPLOYEE,
|
||||
warning: null,
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedValue = rawValue.toLowerCase();
|
||||
if (normalizedValue.includes("freelancer")) {
|
||||
return { resourceType: ResourceType.FREELANCER, warning: null };
|
||||
}
|
||||
if (normalizedValue.includes("intern")) {
|
||||
return { resourceType: ResourceType.INTERN, warning: null };
|
||||
}
|
||||
if (normalizedValue.includes("student")) {
|
||||
return { resourceType: ResourceType.STUDENT, warning: null };
|
||||
}
|
||||
if (normalizedValue.includes("apprentice")) {
|
||||
return { resourceType: ResourceType.APPRENTICE, warning: null };
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedValue === "production studios" ||
|
||||
normalizedValue === "near&offshore" ||
|
||||
normalizedValue === "accenture" ||
|
||||
normalizedValue === "long-term absence"
|
||||
) {
|
||||
return {
|
||||
resourceType: ResourceType.EMPLOYEE,
|
||||
warning: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
resourceType: ResourceType.EMPLOYEE,
|
||||
warning: `Unknown MV Ressource Type "${rawValue}" mapped to EMPLOYEE`,
|
||||
};
|
||||
}
|
||||
|
||||
export function createAvailabilityFromFte(fte: number | null): Prisma.InputJsonValue {
|
||||
return createWeekdayAvailabilityFromFte(fte ?? 1) as unknown as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
export function buildBatchSummaryEntry(summary: Record<string, unknown>): Prisma.InputJsonValue {
|
||||
return summary as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
export async function ensureImportBatch(
|
||||
db: Pick<PrismaClient, "importBatch">,
|
||||
input: {
|
||||
chargeabilitySourceFile?: string;
|
||||
importBatchId?: string;
|
||||
notes?: string | null;
|
||||
planningSourceFile?: string;
|
||||
referenceSourceFile?: string;
|
||||
},
|
||||
): Promise<{ id: string; summary: Record<string, unknown> }> {
|
||||
if (input.importBatchId) {
|
||||
const existing = await db.importBatch.findUnique({
|
||||
where: { id: input.importBatchId },
|
||||
select: { id: true, summary: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new Error(`Import batch "${input.importBatchId}" not found`);
|
||||
}
|
||||
|
||||
const updated = await db.importBatch.update({
|
||||
where: { id: input.importBatchId },
|
||||
data: {
|
||||
status: ImportBatchStatus.STAGING,
|
||||
...(input.referenceSourceFile !== undefined
|
||||
? { referenceSourceFile: input.referenceSourceFile }
|
||||
: {}),
|
||||
...(input.chargeabilitySourceFile !== undefined
|
||||
? { chargeabilitySourceFile: input.chargeabilitySourceFile }
|
||||
: {}),
|
||||
...(input.planningSourceFile !== undefined
|
||||
? { planningSourceFile: input.planningSourceFile }
|
||||
: {}),
|
||||
...(input.notes !== undefined ? { notes: input.notes } : {}),
|
||||
startedAt: new Date(),
|
||||
},
|
||||
select: { id: true, summary: true },
|
||||
});
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
summary: toJsonObject(updated.summary),
|
||||
};
|
||||
}
|
||||
|
||||
const created = await db.importBatch.create({
|
||||
data: {
|
||||
sourceSystem: "DISPO_V2",
|
||||
status: ImportBatchStatus.STAGING,
|
||||
...(input.referenceSourceFile !== undefined
|
||||
? { referenceSourceFile: input.referenceSourceFile }
|
||||
: {}),
|
||||
...(input.chargeabilitySourceFile !== undefined
|
||||
? { chargeabilitySourceFile: input.chargeabilitySourceFile }
|
||||
: {}),
|
||||
...(input.planningSourceFile !== undefined
|
||||
? { planningSourceFile: input.planningSourceFile }
|
||||
: {}),
|
||||
...(input.notes !== undefined ? { notes: input.notes } : {}),
|
||||
startedAt: new Date(),
|
||||
},
|
||||
select: { id: true, summary: true },
|
||||
});
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
summary: toJsonObject(created.summary),
|
||||
};
|
||||
}
|
||||
|
||||
export async function finalizeImportBatchStage(
|
||||
db: Pick<PrismaClient, "importBatch">,
|
||||
input: {
|
||||
batchId: string;
|
||||
existingSummary: Record<string, unknown>;
|
||||
key: "chargeability" | "planning" | "projectResolution" | "reference" | "roster";
|
||||
summary: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
const nextSummary = {
|
||||
...input.existingSummary,
|
||||
[input.key]: input.summary,
|
||||
};
|
||||
|
||||
await db.importBatch.update({
|
||||
where: { id: input.batchId },
|
||||
data: {
|
||||
status: ImportBatchStatus.STAGED,
|
||||
stagedAt: new Date(),
|
||||
summary: buildBatchSummaryEntry(nextSummary),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function toJsonObject(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function resolveCanonicalEnterpriseIdentity(value: string | null): string | null {
|
||||
return value ? normalizeCanonicalResourceIdentity(value) : null;
|
||||
}
|
||||
|
||||
export function createSourceTrace(
|
||||
sourceKind: DispoImportSourceKind,
|
||||
sourceWorkbook: string,
|
||||
sourceSheet: string,
|
||||
sourceRow: number,
|
||||
sourceColumn?: string | null,
|
||||
) {
|
||||
return {
|
||||
sourceKind,
|
||||
sourceWorkbook,
|
||||
sourceSheet,
|
||||
sourceRow,
|
||||
...(sourceColumn !== undefined ? { sourceColumn } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export const DISPO_REFERENCE_SHEET = "EID-Attr";
|
||||
export const DISPO_PROJECT_REFERENCE_SHEET = "Project-Attr";
|
||||
export const DISPO_CHARGEABILITY_SHEET = "ChgFC";
|
||||
export const DISPO_PLANNING_SHEET = "Dispo";
|
||||
export const DISPO_ROSTER_SHEET = "DispoRoster";
|
||||
export const DISPO_ROSTER_SAP_SHEET = "SAP_data";
|
||||
|
||||
export { DispoImportSourceKind, DispoStagedRecordType, StagedRecordStatus };
|
||||
Reference in New Issue
Block a user