Autosave: 20260404-200433
This commit is contained in:
parent
beda38dd14
commit
8514d4078a
@ -106,6 +106,27 @@ const SHEET_DEFINITIONS = [
|
|||||||
],
|
],
|
||||||
requiredHeaders: ['property', 'unit_type', 'unit_number'],
|
requiredHeaders: ['property', 'unit_type', 'unit_number'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'negotiated_rates',
|
||||||
|
label: 'Negotiated Rates',
|
||||||
|
aliases: ['negotiated_rates', 'negotiated rates', 'negotiated-rates', 'negotiated rate'],
|
||||||
|
allowedHeaders: [
|
||||||
|
'id',
|
||||||
|
'tenant',
|
||||||
|
'organization',
|
||||||
|
'property',
|
||||||
|
'unit_type',
|
||||||
|
'rate_type',
|
||||||
|
'rate_amount',
|
||||||
|
'valid_from',
|
||||||
|
'valid_until',
|
||||||
|
'minimum_stay_nights',
|
||||||
|
'is_active',
|
||||||
|
'notes',
|
||||||
|
'importHash',
|
||||||
|
],
|
||||||
|
requiredHeaders: ['property', 'rate_type', 'rate_amount'],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const SHEET_DEFINITION_BY_KEY = SHEET_DEFINITIONS.reduce((accumulator, definition) => {
|
const SHEET_DEFINITION_BY_KEY = SHEET_DEFINITIONS.reduce((accumulator, definition) => {
|
||||||
@ -142,6 +163,67 @@ const normalizeRowValue = (value) => {
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeNegotiatedRateType = (value) => {
|
||||||
|
if (isBlankValue(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawValue = String(value).trim().toLowerCase();
|
||||||
|
const normalizedValue = rawValue.replace(/[^a-z0-9]+/g, ' ').trim();
|
||||||
|
|
||||||
|
if (['nightly', 'night', 'nightly rate', 'per night', 'per nightly'].includes(normalizedValue)) {
|
||||||
|
return 'nightly';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['monthly', 'month', 'monthly rate', 'per month', 'per monthly'].includes(normalizedValue)) {
|
||||||
|
return 'monthly';
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseOptionalInteger = (value, label) => {
|
||||||
|
if (isBlankValue(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
|
||||||
|
if (!Number.isInteger(parsed)) {
|
||||||
|
throw createBadRequestError(`${label} must be a whole number.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseRequiredDecimal = (value, label) => {
|
||||||
|
if (isBlankValue(value)) {
|
||||||
|
throw createBadRequestError(`${label} is required.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
throw createBadRequestError(`${label} must be a valid number.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseOptionalDate = (value, label) => {
|
||||||
|
if (isBlankValue(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(value);
|
||||||
|
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
throw createBadRequestError(`${label} must be a valid date.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
const prefixSheetImportError = (error, sheetLabel, rowNumber) => {
|
const prefixSheetImportError = (error, sheetLabel, rowNumber) => {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
return error;
|
return error;
|
||||||
@ -156,6 +238,123 @@ const prefixSheetImportError = (error, sheetLabel, rowNumber) => {
|
|||||||
return error;
|
return error;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveExistingReferenceOrNull = async (resolver, { allowMessages = [] } = {}) => {
|
||||||
|
try {
|
||||||
|
return await resolver();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.message || '';
|
||||||
|
const isAllowedMessage = allowMessages.some((pattern) =>
|
||||||
|
pattern instanceof RegExp ? pattern.test(message) : pattern === message,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error?.code === 400 && (/ was not found\.$/.test(message) || isAllowedMessage)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeWorkbookReference = (value) => {
|
||||||
|
if (isBlankValue(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value).trim().toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const createWorkbookReferenceRegistry = () => ({
|
||||||
|
tenants: [],
|
||||||
|
organizations: [],
|
||||||
|
properties: [],
|
||||||
|
unit_types: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const getWorkbookReferenceRegistry = (context) => {
|
||||||
|
if (!context.workbookReferences) {
|
||||||
|
context.workbookReferences = createWorkbookReferenceRegistry();
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.workbookReferences;
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerWorkbookReference = (
|
||||||
|
context,
|
||||||
|
entityKey,
|
||||||
|
{ id, references = [], tenantId = null, organizationId = null, propertyId = null } = {},
|
||||||
|
) => {
|
||||||
|
if (!entityKey || !id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = getWorkbookReferenceRegistry(context);
|
||||||
|
|
||||||
|
if (!registry[entityKey]) {
|
||||||
|
registry[entityKey] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
registry[entityKey].push({
|
||||||
|
id,
|
||||||
|
references: Array.from(new Set(references.map((value) => normalizeWorkbookReference(value)).filter(Boolean))),
|
||||||
|
tenantId: tenantId || null,
|
||||||
|
organizationId: organizationId || null,
|
||||||
|
propertyId: propertyId || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveWorkbookReference = (
|
||||||
|
context,
|
||||||
|
entityKey,
|
||||||
|
label,
|
||||||
|
reference,
|
||||||
|
{ tenantId = null, organizationId = null, propertyId = null } = {},
|
||||||
|
) => {
|
||||||
|
const normalizedReference = normalizeWorkbookReference(reference);
|
||||||
|
|
||||||
|
if (!normalizedReference) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = getWorkbookReferenceRegistry(context);
|
||||||
|
const uniqueMatchIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
(registry[entityKey] || [])
|
||||||
|
.filter((entry) => entry.references.includes(normalizedReference))
|
||||||
|
.filter((entry) => !tenantId || entry.tenantId === tenantId)
|
||||||
|
.filter((entry) => !organizationId || entry.organizationId === organizationId)
|
||||||
|
.filter((entry) => !propertyId || entry.propertyId === propertyId)
|
||||||
|
.map((entry) => entry.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!uniqueMatchIds.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uniqueMatchIds.length > 1) {
|
||||||
|
throw createBadRequestError(`${label} "${reference}" matches multiple workbook rows. Use the ID instead.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueMatchIds[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveWorkbookReferenceOrDb = async ({
|
||||||
|
context,
|
||||||
|
entityKey,
|
||||||
|
label,
|
||||||
|
reference,
|
||||||
|
scope = {},
|
||||||
|
resolver,
|
||||||
|
}) => {
|
||||||
|
const workbookMatchId = resolveWorkbookReference(context, entityKey, label, reference, scope);
|
||||||
|
|
||||||
|
if (workbookMatchId) {
|
||||||
|
return workbookMatchId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolver();
|
||||||
|
};
|
||||||
|
|
||||||
const validateWorkbookFile = (req) => {
|
const validateWorkbookFile = (req) => {
|
||||||
if (!req.file || !req.file.buffer || !req.file.buffer.length) {
|
if (!req.file || !req.file.buffer || !req.file.buffer.length) {
|
||||||
throw createBadRequestError('Workbook upload is empty.');
|
throw createBadRequestError('Workbook upload is empty.');
|
||||||
@ -319,6 +518,10 @@ const ensurePermissionsForSheets = (currentUser, sheets) => {
|
|||||||
requiredPermissions.push('CREATE_UNITS');
|
requiredPermissions.push('CREATE_UNITS');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sheets.negotiated_rates.rows.length) {
|
||||||
|
requiredPermissions.push('CREATE_NEGOTIATED_RATES');
|
||||||
|
}
|
||||||
|
|
||||||
const missingPermissions = requiredPermissions.filter((permission) => !permissionSet.has(permission));
|
const missingPermissions = requiredPermissions.filter((permission) => !permissionSet.has(permission));
|
||||||
|
|
||||||
if (missingPermissions.length) {
|
if (missingPermissions.length) {
|
||||||
@ -335,8 +538,20 @@ const getOrCreateTenantId = async (row, context) => {
|
|||||||
const reference = normalizedRow.id || normalizedRow.slug || normalizedRow.name;
|
const reference = normalizedRow.id || normalizedRow.slug || normalizedRow.name;
|
||||||
|
|
||||||
if (reference) {
|
if (reference) {
|
||||||
const existingTenantId = await resolveTenantReference(reference, context.transaction);
|
const existingTenantId = await resolveWorkbookReferenceOrDb({
|
||||||
|
context,
|
||||||
|
entityKey: 'tenants',
|
||||||
|
label: 'Tenant',
|
||||||
|
reference,
|
||||||
|
resolver: () =>
|
||||||
|
resolveExistingReferenceOrNull(() => resolveTenantReference(reference, context.transaction)),
|
||||||
|
});
|
||||||
|
|
||||||
if (existingTenantId) {
|
if (existingTenantId) {
|
||||||
|
registerWorkbookReference(context, 'tenants', {
|
||||||
|
id: existingTenantId,
|
||||||
|
references: [normalizedRow.id, normalizedRow.slug, normalizedRow.name],
|
||||||
|
});
|
||||||
context.summary.tenants.reused += 1;
|
context.summary.tenants.reused += 1;
|
||||||
return existingTenantId;
|
return existingTenantId;
|
||||||
}
|
}
|
||||||
@ -351,6 +566,11 @@ const getOrCreateTenantId = async (row, context) => {
|
|||||||
transaction: context.transaction,
|
transaction: context.transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
registerWorkbookReference(context, 'tenants', {
|
||||||
|
id: createdTenant.id,
|
||||||
|
references: [normalizedRow.id, normalizedRow.slug, normalizedRow.name],
|
||||||
|
});
|
||||||
|
|
||||||
context.summary.tenants.created += 1;
|
context.summary.tenants.created += 1;
|
||||||
return createdTenant.id;
|
return createdTenant.id;
|
||||||
};
|
};
|
||||||
@ -383,7 +603,14 @@ const getOrCreateOrganizationId = async (row, context) => {
|
|||||||
let tenantId = null;
|
let tenantId = null;
|
||||||
|
|
||||||
if (normalizedRow.tenant) {
|
if (normalizedRow.tenant) {
|
||||||
tenantId = await resolveTenantReference(normalizedRow.tenant, context.transaction);
|
tenantId = await resolveWorkbookReferenceOrDb({
|
||||||
|
context,
|
||||||
|
entityKey: 'tenants',
|
||||||
|
label: 'Tenant',
|
||||||
|
reference: normalizedRow.tenant,
|
||||||
|
resolver: () => resolveTenantReference(normalizedRow.tenant, context.transaction),
|
||||||
|
});
|
||||||
|
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
throw createBadRequestError(`Tenant "${normalizedRow.tenant}" was not found.`);
|
throw createBadRequestError(`Tenant "${normalizedRow.tenant}" was not found.`);
|
||||||
}
|
}
|
||||||
@ -392,13 +619,32 @@ const getOrCreateOrganizationId = async (row, context) => {
|
|||||||
const reference = normalizedRow.id || normalizedRow.name;
|
const reference = normalizedRow.id || normalizedRow.name;
|
||||||
|
|
||||||
if (reference) {
|
if (reference) {
|
||||||
const existingOrganizationId = await resolveOrganizationReference(reference, {
|
const existingOrganizationId = await resolveWorkbookReferenceOrDb({
|
||||||
tenantId,
|
context,
|
||||||
transaction: context.transaction,
|
entityKey: 'organizations',
|
||||||
|
label: 'Organization',
|
||||||
|
reference,
|
||||||
|
scope: { tenantId },
|
||||||
|
resolver: () =>
|
||||||
|
resolveExistingReferenceOrNull(
|
||||||
|
() =>
|
||||||
|
resolveOrganizationReference(reference, {
|
||||||
|
tenantId,
|
||||||
|
transaction: context.transaction,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
allowMessages: ['The selected tenant has no organizations available for import.'],
|
||||||
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingOrganizationId) {
|
if (existingOrganizationId) {
|
||||||
await linkOrganizationToTenant(tenantId, existingOrganizationId, context.transaction);
|
await linkOrganizationToTenant(tenantId, existingOrganizationId, context.transaction);
|
||||||
|
registerWorkbookReference(context, 'organizations', {
|
||||||
|
id: existingOrganizationId,
|
||||||
|
references: [normalizedRow.id, normalizedRow.name],
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
context.summary.organizations.reused += 1;
|
context.summary.organizations.reused += 1;
|
||||||
return existingOrganizationId;
|
return existingOrganizationId;
|
||||||
}
|
}
|
||||||
@ -415,6 +661,12 @@ const getOrCreateOrganizationId = async (row, context) => {
|
|||||||
|
|
||||||
await linkOrganizationToTenant(tenantId, createdOrganization.id, context.transaction);
|
await linkOrganizationToTenant(tenantId, createdOrganization.id, context.transaction);
|
||||||
|
|
||||||
|
registerWorkbookReference(context, 'organizations', {
|
||||||
|
id: createdOrganization.id,
|
||||||
|
references: [normalizedRow.id, normalizedRow.name],
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
context.summary.organizations.created += 1;
|
context.summary.organizations.created += 1;
|
||||||
return createdOrganization.id;
|
return createdOrganization.id;
|
||||||
};
|
};
|
||||||
@ -425,13 +677,27 @@ const getOrCreatePropertyId = async (row, context) => {
|
|||||||
let organizationId = null;
|
let organizationId = null;
|
||||||
|
|
||||||
if (normalizedRow.tenant) {
|
if (normalizedRow.tenant) {
|
||||||
tenantId = await resolveTenantReference(normalizedRow.tenant, context.transaction);
|
tenantId = await resolveWorkbookReferenceOrDb({
|
||||||
|
context,
|
||||||
|
entityKey: 'tenants',
|
||||||
|
label: 'Tenant',
|
||||||
|
reference: normalizedRow.tenant,
|
||||||
|
resolver: () => resolveTenantReference(normalizedRow.tenant, context.transaction),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedRow.organizations) {
|
if (normalizedRow.organizations) {
|
||||||
organizationId = await resolveOrganizationReference(normalizedRow.organizations, {
|
organizationId = await resolveWorkbookReferenceOrDb({
|
||||||
tenantId,
|
context,
|
||||||
transaction: context.transaction,
|
entityKey: 'organizations',
|
||||||
|
label: 'Organization',
|
||||||
|
reference: normalizedRow.organizations,
|
||||||
|
scope: { tenantId },
|
||||||
|
resolver: () =>
|
||||||
|
resolveOrganizationReference(normalizedRow.organizations, {
|
||||||
|
tenantId,
|
||||||
|
transaction: context.transaction,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -446,14 +712,30 @@ const getOrCreatePropertyId = async (row, context) => {
|
|||||||
const reference = normalizedRow.id || normalizedRow.code || normalizedRow.name;
|
const reference = normalizedRow.id || normalizedRow.code || normalizedRow.name;
|
||||||
|
|
||||||
if (reference) {
|
if (reference) {
|
||||||
const existingPropertyId = await resolvePropertyReference(reference, {
|
const existingPropertyId = await resolveWorkbookReferenceOrDb({
|
||||||
currentUser: context.currentUser,
|
context,
|
||||||
organizationId,
|
entityKey: 'properties',
|
||||||
tenantId,
|
label: 'Property',
|
||||||
transaction: context.transaction,
|
reference,
|
||||||
|
scope: { tenantId, organizationId },
|
||||||
|
resolver: () =>
|
||||||
|
resolveExistingReferenceOrNull(() =>
|
||||||
|
resolvePropertyReference(reference, {
|
||||||
|
currentUser: context.currentUser,
|
||||||
|
organizationId,
|
||||||
|
tenantId,
|
||||||
|
transaction: context.transaction,
|
||||||
|
}),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingPropertyId) {
|
if (existingPropertyId) {
|
||||||
|
registerWorkbookReference(context, 'properties', {
|
||||||
|
id: existingPropertyId,
|
||||||
|
references: [normalizedRow.id, normalizedRow.code, normalizedRow.name],
|
||||||
|
tenantId,
|
||||||
|
organizationId,
|
||||||
|
});
|
||||||
context.summary.properties.reused += 1;
|
context.summary.properties.reused += 1;
|
||||||
return existingPropertyId;
|
return existingPropertyId;
|
||||||
}
|
}
|
||||||
@ -471,15 +753,29 @@ const getOrCreatePropertyId = async (row, context) => {
|
|||||||
transaction: context.transaction,
|
transaction: context.transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
registerWorkbookReference(context, 'properties', {
|
||||||
|
id: createdProperty.id,
|
||||||
|
references: [normalizedRow.id, normalizedRow.code, normalizedRow.name],
|
||||||
|
tenantId,
|
||||||
|
organizationId,
|
||||||
|
});
|
||||||
|
|
||||||
context.summary.properties.created += 1;
|
context.summary.properties.created += 1;
|
||||||
return createdProperty.id;
|
return createdProperty.id;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getOrCreateUnitTypeId = async (row, context) => {
|
const getOrCreateUnitTypeId = async (row, context) => {
|
||||||
const normalizedRow = normalizeInventoryImportRow(row);
|
const normalizedRow = normalizeInventoryImportRow(row);
|
||||||
const propertyId = await resolvePropertyReference(normalizedRow.property, {
|
const propertyId = await resolveWorkbookReferenceOrDb({
|
||||||
currentUser: context.currentUser,
|
context,
|
||||||
transaction: context.transaction,
|
entityKey: 'properties',
|
||||||
|
label: 'Property',
|
||||||
|
reference: normalizedRow.property,
|
||||||
|
resolver: () =>
|
||||||
|
resolvePropertyReference(normalizedRow.property, {
|
||||||
|
currentUser: context.currentUser,
|
||||||
|
transaction: context.transaction,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!propertyId) {
|
if (!propertyId) {
|
||||||
@ -490,14 +786,33 @@ const getOrCreateUnitTypeId = async (row, context) => {
|
|||||||
const reference = normalizedRow.id || normalizedRow.code || normalizedRow.name;
|
const reference = normalizedRow.id || normalizedRow.code || normalizedRow.name;
|
||||||
|
|
||||||
if (reference) {
|
if (reference) {
|
||||||
const existingUnitTypeId = await resolveUnitTypeReference(reference, {
|
const existingUnitTypeId = await resolveWorkbookReferenceOrDb({
|
||||||
currentUser: context.currentUser,
|
context,
|
||||||
organizationId: property?.organizationsId || null,
|
entityKey: 'unit_types',
|
||||||
propertyId,
|
label: 'Unit type',
|
||||||
transaction: context.transaction,
|
reference,
|
||||||
|
scope: {
|
||||||
|
organizationId: property?.organizationsId || null,
|
||||||
|
propertyId,
|
||||||
|
},
|
||||||
|
resolver: () =>
|
||||||
|
resolveExistingReferenceOrNull(() =>
|
||||||
|
resolveUnitTypeReference(reference, {
|
||||||
|
currentUser: context.currentUser,
|
||||||
|
organizationId: property?.organizationsId || null,
|
||||||
|
propertyId,
|
||||||
|
transaction: context.transaction,
|
||||||
|
}),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingUnitTypeId) {
|
if (existingUnitTypeId) {
|
||||||
|
registerWorkbookReference(context, 'unit_types', {
|
||||||
|
id: existingUnitTypeId,
|
||||||
|
references: [normalizedRow.id, normalizedRow.code, normalizedRow.name],
|
||||||
|
organizationId: property?.organizationsId || null,
|
||||||
|
propertyId,
|
||||||
|
});
|
||||||
context.summary.unit_types.reused += 1;
|
context.summary.unit_types.reused += 1;
|
||||||
return existingUnitTypeId;
|
return existingUnitTypeId;
|
||||||
}
|
}
|
||||||
@ -515,6 +830,13 @@ const getOrCreateUnitTypeId = async (row, context) => {
|
|||||||
transaction: context.transaction,
|
transaction: context.transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
registerWorkbookReference(context, 'unit_types', {
|
||||||
|
id: createdUnitType.id,
|
||||||
|
references: [normalizedRow.id, normalizedRow.code, normalizedRow.name],
|
||||||
|
organizationId: property?.organizationsId || null,
|
||||||
|
propertyId,
|
||||||
|
});
|
||||||
|
|
||||||
context.summary.unit_types.created += 1;
|
context.summary.unit_types.created += 1;
|
||||||
return createdUnitType.id;
|
return createdUnitType.id;
|
||||||
};
|
};
|
||||||
@ -559,11 +881,209 @@ const resolveExistingUnit = async ({ id, unitNumber, propertyId, transaction })
|
|||||||
return units[0];
|
return units[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveExistingNegotiatedRate = async ({
|
||||||
|
id,
|
||||||
|
importHash,
|
||||||
|
propertyId,
|
||||||
|
unitTypeId,
|
||||||
|
rateType,
|
||||||
|
rateAmount,
|
||||||
|
validFrom,
|
||||||
|
validUntil,
|
||||||
|
transaction,
|
||||||
|
}) => {
|
||||||
|
const filters = [];
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
filters.push({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importHash) {
|
||||||
|
filters.push({ importHash });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (propertyId && rateType) {
|
||||||
|
filters.push({
|
||||||
|
propertyId,
|
||||||
|
unit_typeId: unitTypeId || null,
|
||||||
|
rate_type: rateType,
|
||||||
|
rate_amount: rateAmount,
|
||||||
|
valid_from: validFrom || null,
|
||||||
|
valid_until: validUntil || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filters.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const negotiatedRates = await db.negotiated_rates.findAll({
|
||||||
|
where: {
|
||||||
|
[Op.or]: filters,
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
limit: 2,
|
||||||
|
order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!negotiatedRates.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (negotiatedRates.length > 1) {
|
||||||
|
throw createBadRequestError('Negotiated rate matches multiple records. Use the ID or importHash instead.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return negotiatedRates[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOrCreateNegotiatedRateId = async (row, context) => {
|
||||||
|
const normalizedRow = normalizeInventoryImportRow(row);
|
||||||
|
let tenantId = null;
|
||||||
|
let organizationId = null;
|
||||||
|
|
||||||
|
if (normalizedRow.tenant) {
|
||||||
|
tenantId = await resolveWorkbookReferenceOrDb({
|
||||||
|
context,
|
||||||
|
entityKey: 'tenants',
|
||||||
|
label: 'Tenant',
|
||||||
|
reference: normalizedRow.tenant,
|
||||||
|
resolver: () => resolveTenantReference(normalizedRow.tenant, context.transaction),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedRow.organizations) {
|
||||||
|
organizationId = await resolveWorkbookReferenceOrDb({
|
||||||
|
context,
|
||||||
|
entityKey: 'organizations',
|
||||||
|
label: 'Organization',
|
||||||
|
reference: normalizedRow.organizations,
|
||||||
|
scope: { tenantId },
|
||||||
|
resolver: () =>
|
||||||
|
resolveOrganizationReference(normalizedRow.organizations, {
|
||||||
|
tenantId,
|
||||||
|
transaction: context.transaction,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const propertyId = await resolveWorkbookReferenceOrDb({
|
||||||
|
context,
|
||||||
|
entityKey: 'properties',
|
||||||
|
label: 'Property',
|
||||||
|
reference: normalizedRow.property,
|
||||||
|
scope: { tenantId, organizationId },
|
||||||
|
resolver: () =>
|
||||||
|
resolvePropertyReference(normalizedRow.property, {
|
||||||
|
currentUser: context.currentUser,
|
||||||
|
organizationId,
|
||||||
|
tenantId,
|
||||||
|
transaction: context.transaction,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!propertyId) {
|
||||||
|
throw createBadRequestError('Property is required for negotiated rate import.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const property = await loadPropertyContext(propertyId, context.transaction);
|
||||||
|
const propertyOrganizationId = property?.organizationsId || null;
|
||||||
|
|
||||||
|
if (organizationId && propertyOrganizationId && organizationId !== propertyOrganizationId) {
|
||||||
|
throw createBadRequestError('Property does not belong to the provided organization.');
|
||||||
|
}
|
||||||
|
|
||||||
|
organizationId = propertyOrganizationId || organizationId || null;
|
||||||
|
|
||||||
|
let unitTypeId = null;
|
||||||
|
if (normalizedRow.unit_type) {
|
||||||
|
unitTypeId = await resolveWorkbookReferenceOrDb({
|
||||||
|
context,
|
||||||
|
entityKey: 'unit_types',
|
||||||
|
label: 'Unit type',
|
||||||
|
reference: normalizedRow.unit_type,
|
||||||
|
scope: { organizationId, propertyId },
|
||||||
|
resolver: () =>
|
||||||
|
resolveUnitTypeReference(normalizedRow.unit_type, {
|
||||||
|
currentUser: context.currentUser,
|
||||||
|
organizationId,
|
||||||
|
propertyId,
|
||||||
|
transaction: context.transaction,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateType = normalizeNegotiatedRateType(normalizedRow.rate_type);
|
||||||
|
|
||||||
|
if (!['nightly', 'monthly'].includes(rateType)) {
|
||||||
|
const providedRateType = isBlankValue(normalizedRow.rate_type)
|
||||||
|
? 'blank'
|
||||||
|
: String(normalizedRow.rate_type).trim();
|
||||||
|
throw createBadRequestError(`Rate type "${providedRateType}" is invalid. Use nightly or monthly.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateAmount = parseRequiredDecimal(normalizedRow.rate_amount, 'Rate amount');
|
||||||
|
const validFrom = parseOptionalDate(normalizedRow.valid_from, 'Valid from');
|
||||||
|
const validUntil = parseOptionalDate(normalizedRow.valid_until, 'Valid until');
|
||||||
|
const minimumStayNights = parseOptionalInteger(normalizedRow.minimum_stay_nights, 'Minimum stay nights');
|
||||||
|
|
||||||
|
if (validFrom && validUntil && validUntil.getTime() < validFrom.getTime()) {
|
||||||
|
throw createBadRequestError('Valid until must be on or after valid from.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingNegotiatedRate = await resolveExistingNegotiatedRate({
|
||||||
|
id: normalizedRow.id || null,
|
||||||
|
importHash: normalizedRow.importHash || null,
|
||||||
|
propertyId,
|
||||||
|
unitTypeId,
|
||||||
|
rateType,
|
||||||
|
rateAmount,
|
||||||
|
validFrom,
|
||||||
|
validUntil,
|
||||||
|
transaction: context.transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingNegotiatedRate) {
|
||||||
|
context.summary.negotiated_rates.reused += 1;
|
||||||
|
return existingNegotiatedRate.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdNegotiatedRate = await db.negotiated_rates.create(
|
||||||
|
{
|
||||||
|
id: normalizedRow.id || undefined,
|
||||||
|
rate_type: rateType,
|
||||||
|
rate_amount: rateAmount,
|
||||||
|
valid_from: validFrom,
|
||||||
|
valid_until: validUntil,
|
||||||
|
minimum_stay_nights: minimumStayNights,
|
||||||
|
is_active: typeof normalizedRow.is_active === 'boolean' ? normalizedRow.is_active : false,
|
||||||
|
notes: isBlankValue(normalizedRow.notes) ? null : normalizedRow.notes,
|
||||||
|
importHash: normalizedRow.importHash || null,
|
||||||
|
organizationId,
|
||||||
|
propertyId,
|
||||||
|
unit_typeId: unitTypeId,
|
||||||
|
createdById: context.currentUser?.id || null,
|
||||||
|
updatedById: context.currentUser?.id || null,
|
||||||
|
},
|
||||||
|
{ transaction: context.transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
context.summary.negotiated_rates.created += 1;
|
||||||
|
return createdNegotiatedRate.id;
|
||||||
|
};
|
||||||
|
|
||||||
const getOrCreateUnitId = async (row, context) => {
|
const getOrCreateUnitId = async (row, context) => {
|
||||||
const normalizedRow = normalizeInventoryImportRow(row);
|
const normalizedRow = normalizeInventoryImportRow(row);
|
||||||
const propertyId = await resolvePropertyReference(normalizedRow.property, {
|
const propertyId = await resolveWorkbookReferenceOrDb({
|
||||||
currentUser: context.currentUser,
|
context,
|
||||||
transaction: context.transaction,
|
entityKey: 'properties',
|
||||||
|
label: 'Property',
|
||||||
|
reference: normalizedRow.property,
|
||||||
|
resolver: () =>
|
||||||
|
resolvePropertyReference(normalizedRow.property, {
|
||||||
|
currentUser: context.currentUser,
|
||||||
|
transaction: context.transaction,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!propertyId) {
|
if (!propertyId) {
|
||||||
@ -571,11 +1091,22 @@ const getOrCreateUnitId = async (row, context) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const property = await loadPropertyContext(propertyId, context.transaction);
|
const property = await loadPropertyContext(propertyId, context.transaction);
|
||||||
const unitTypeId = await resolveUnitTypeReference(normalizedRow.unit_type, {
|
const unitTypeId = await resolveWorkbookReferenceOrDb({
|
||||||
currentUser: context.currentUser,
|
context,
|
||||||
organizationId: property?.organizationsId || null,
|
entityKey: 'unit_types',
|
||||||
propertyId,
|
label: 'Unit type',
|
||||||
transaction: context.transaction,
|
reference: normalizedRow.unit_type,
|
||||||
|
scope: {
|
||||||
|
organizationId: property?.organizationsId || null,
|
||||||
|
propertyId,
|
||||||
|
},
|
||||||
|
resolver: () =>
|
||||||
|
resolveUnitTypeReference(normalizedRow.unit_type, {
|
||||||
|
currentUser: context.currentUser,
|
||||||
|
organizationId: property?.organizationsId || null,
|
||||||
|
propertyId,
|
||||||
|
transaction: context.transaction,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!unitTypeId) {
|
if (!unitTypeId) {
|
||||||
@ -641,6 +1172,7 @@ module.exports = class PortfolioWorkbookImportService {
|
|||||||
properties: { created: 0, reused: 0 },
|
properties: { created: 0, reused: 0 },
|
||||||
unit_types: { created: 0, reused: 0 },
|
unit_types: { created: 0, reused: 0 },
|
||||||
units: { created: 0, reused: 0 },
|
units: { created: 0, reused: 0 },
|
||||||
|
negotiated_rates: { created: 0, reused: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
@ -690,6 +1222,14 @@ module.exports = class PortfolioWorkbookImportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < sheets.negotiated_rates.rows.length; index += 1) {
|
||||||
|
try {
|
||||||
|
await getOrCreateNegotiatedRateId(sheets.negotiated_rates.rows[index], context);
|
||||||
|
} catch (error) {
|
||||||
|
throw prefixSheetImportError(error, SHEET_DEFINITION_BY_KEY.negotiated_rates.label, index + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -100,4 +100,26 @@ export const portfolioWorkbookSheets: PortfolioWorkbookSheet[] = [
|
|||||||
importHash: 'unit-1205-001',
|
importHash: 'unit-1205-001',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'negotiated_rates',
|
||||||
|
label: 'negotiated_rates',
|
||||||
|
description: 'Create negotiated rates after properties and unit types. Property is required; unit_type is optional when the rate applies to the full property.',
|
||||||
|
columns: ['id', 'tenant', 'organization', 'property', 'unit_type', 'rate_type', 'rate_amount', 'valid_from', 'valid_until', 'minimum_stay_nights', 'is_active', 'notes', 'importHash'],
|
||||||
|
requiredColumns: ['property', 'rate_type', 'rate_amount'],
|
||||||
|
exampleRow: {
|
||||||
|
id: '',
|
||||||
|
tenant: 'corporate-stay-portal',
|
||||||
|
organization: 'Acme Hospitality',
|
||||||
|
property: 'SUNSET-SF',
|
||||||
|
unit_type: 'STD-STUDIO',
|
||||||
|
rate_type: 'monthly',
|
||||||
|
rate_amount: '3990',
|
||||||
|
valid_from: '2026-05-01',
|
||||||
|
valid_until: '2026-12-31',
|
||||||
|
minimum_stay_nights: '30',
|
||||||
|
is_active: 'true',
|
||||||
|
notes: 'Preferred client housing program rate',
|
||||||
|
importHash: 'neg-rate-acme-studio-2026',
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -92,6 +92,7 @@ export const buildMenuAside = (currentUser?: any): MenuAsideItem[] => {
|
|||||||
'CREATE_PROPERTIES',
|
'CREATE_PROPERTIES',
|
||||||
'CREATE_UNIT_TYPES',
|
'CREATE_UNIT_TYPES',
|
||||||
'CREATE_UNITS',
|
'CREATE_UNITS',
|
||||||
|
'CREATE_NEGOTIATED_RATES',
|
||||||
],
|
],
|
||||||
menu: [
|
menu: [
|
||||||
{
|
{
|
||||||
@ -104,6 +105,7 @@ export const buildMenuAside = (currentUser?: any): MenuAsideItem[] => {
|
|||||||
'CREATE_PROPERTIES',
|
'CREATE_PROPERTIES',
|
||||||
'CREATE_UNIT_TYPES',
|
'CREATE_UNIT_TYPES',
|
||||||
'CREATE_UNITS',
|
'CREATE_UNITS',
|
||||||
|
'CREATE_NEGOTIATED_RATES',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -34,6 +34,7 @@ const createPermissions = [
|
|||||||
'CREATE_PROPERTIES',
|
'CREATE_PROPERTIES',
|
||||||
'CREATE_UNIT_TYPES',
|
'CREATE_UNIT_TYPES',
|
||||||
'CREATE_UNITS',
|
'CREATE_UNITS',
|
||||||
|
'CREATE_NEGOTIATED_RATES',
|
||||||
];
|
];
|
||||||
|
|
||||||
const summaryLabels: Record<string, string> = {
|
const summaryLabels: Record<string, string> = {
|
||||||
@ -42,6 +43,7 @@ const summaryLabels: Record<string, string> = {
|
|||||||
properties: 'Properties',
|
properties: 'Properties',
|
||||||
unit_types: 'Unit Types',
|
unit_types: 'Unit Types',
|
||||||
units: 'Units',
|
units: 'Units',
|
||||||
|
negotiated_rates: 'Negotiated Rates',
|
||||||
};
|
};
|
||||||
|
|
||||||
const PortfolioImportPage = () => {
|
const PortfolioImportPage = () => {
|
||||||
@ -136,11 +138,11 @@ const PortfolioImportPage = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className='text-lg font-semibold text-gray-900 dark:text-slate-100'>One workbook for portfolio onboarding</h2>
|
<h2 className='text-lg font-semibold text-gray-900 dark:text-slate-100'>One workbook for portfolio onboarding</h2>
|
||||||
<p className='mt-2 text-sm text-gray-600 dark:text-slate-300'>
|
<p className='mt-2 text-sm text-gray-600 dark:text-slate-300'>
|
||||||
Upload a single Excel workbook to create linked tenants, organizations, properties, unit types, and units in dependency order.
|
Upload a single Excel workbook to create linked tenants, organizations, properties, unit types, units, and negotiated rates in dependency order.
|
||||||
Existing records are reused when the workbook references a matching ID or business key.
|
Existing records are reused when the workbook references a matching ID or business key.
|
||||||
</p>
|
</p>
|
||||||
<p className='mt-2 text-sm text-gray-600 dark:text-slate-300'>
|
<p className='mt-2 text-sm text-gray-600 dark:text-slate-300'>
|
||||||
This v1 workbook flow covers portfolio setup sheets only. Negotiated rates stay on their current flow for now.
|
Negotiated rates are linked after units and can reference a property plus an optional unit type.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -193,7 +195,7 @@ const PortfolioImportPage = () => {
|
|||||||
{importResult.workbook.sheets.map((sheet) => `${sheet.name} (${sheet.rows})`).join(', ')}
|
{importResult.workbook.sheets.map((sheet) => `${sheet.name} (${sheet.rows})`).join(', ')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-5'>
|
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-6'>
|
||||||
{workbookSummary.map(([key, value]) => (
|
{workbookSummary.map(([key, value]) => (
|
||||||
<div key={key} className='rounded-xl border border-gray-200 px-4 py-3 dark:border-slate-700'>
|
<div key={key} className='rounded-xl border border-gray-200 px-4 py-3 dark:border-slate-700'>
|
||||||
<div className='text-sm font-semibold text-gray-900 dark:text-slate-100'>{summaryLabels[key] || key}</div>
|
<div className='text-sm font-semibold text-gray-900 dark:text-slate-100'>{summaryLabels[key] || key}</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user