From 8514d4078a1769971961bddb05e4439ee2315250 Mon Sep 17 00:00:00 2001
From: Flatlogic Bot
Date: Sat, 4 Apr 2026 20:04:33 +0000
Subject: [PATCH] Autosave: 20260404-200433
---
.../src/services/portfolioWorkbookImport.js | 600 +++++++++++++++++-
.../src/helpers/portfolioWorkbookSheets.ts | 22 +
frontend/src/menuAside.ts | 2 +
frontend/src/pages/portfolio-import.tsx | 8 +-
4 files changed, 599 insertions(+), 33 deletions(-)
diff --git a/backend/src/services/portfolioWorkbookImport.js b/backend/src/services/portfolioWorkbookImport.js
index bd8131f..1378697 100644
--- a/backend/src/services/portfolioWorkbookImport.js
+++ b/backend/src/services/portfolioWorkbookImport.js
@@ -106,6 +106,27 @@ const SHEET_DEFINITIONS = [
],
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) => {
@@ -142,6 +163,67 @@ const normalizeRowValue = (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) => {
if (!error) {
return error;
@@ -156,6 +238,123 @@ const prefixSheetImportError = (error, sheetLabel, rowNumber) => {
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) => {
if (!req.file || !req.file.buffer || !req.file.buffer.length) {
throw createBadRequestError('Workbook upload is empty.');
@@ -319,6 +518,10 @@ const ensurePermissionsForSheets = (currentUser, sheets) => {
requiredPermissions.push('CREATE_UNITS');
}
+ if (sheets.negotiated_rates.rows.length) {
+ requiredPermissions.push('CREATE_NEGOTIATED_RATES');
+ }
+
const missingPermissions = requiredPermissions.filter((permission) => !permissionSet.has(permission));
if (missingPermissions.length) {
@@ -335,8 +538,20 @@ const getOrCreateTenantId = async (row, context) => {
const reference = normalizedRow.id || normalizedRow.slug || normalizedRow.name;
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) {
+ registerWorkbookReference(context, 'tenants', {
+ id: existingTenantId,
+ references: [normalizedRow.id, normalizedRow.slug, normalizedRow.name],
+ });
context.summary.tenants.reused += 1;
return existingTenantId;
}
@@ -351,6 +566,11 @@ const getOrCreateTenantId = async (row, context) => {
transaction: context.transaction,
});
+ registerWorkbookReference(context, 'tenants', {
+ id: createdTenant.id,
+ references: [normalizedRow.id, normalizedRow.slug, normalizedRow.name],
+ });
+
context.summary.tenants.created += 1;
return createdTenant.id;
};
@@ -383,7 +603,14 @@ const getOrCreateOrganizationId = async (row, context) => {
let tenantId = null;
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) {
throw createBadRequestError(`Tenant "${normalizedRow.tenant}" was not found.`);
}
@@ -392,13 +619,32 @@ const getOrCreateOrganizationId = async (row, context) => {
const reference = normalizedRow.id || normalizedRow.name;
if (reference) {
- const existingOrganizationId = await resolveOrganizationReference(reference, {
- tenantId,
- transaction: context.transaction,
+ const existingOrganizationId = await resolveWorkbookReferenceOrDb({
+ context,
+ 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) {
await linkOrganizationToTenant(tenantId, existingOrganizationId, context.transaction);
+ registerWorkbookReference(context, 'organizations', {
+ id: existingOrganizationId,
+ references: [normalizedRow.id, normalizedRow.name],
+ tenantId,
+ });
context.summary.organizations.reused += 1;
return existingOrganizationId;
}
@@ -415,6 +661,12 @@ const getOrCreateOrganizationId = async (row, context) => {
await linkOrganizationToTenant(tenantId, createdOrganization.id, context.transaction);
+ registerWorkbookReference(context, 'organizations', {
+ id: createdOrganization.id,
+ references: [normalizedRow.id, normalizedRow.name],
+ tenantId,
+ });
+
context.summary.organizations.created += 1;
return createdOrganization.id;
};
@@ -425,13 +677,27 @@ const getOrCreatePropertyId = async (row, context) => {
let organizationId = null;
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) {
- organizationId = await resolveOrganizationReference(normalizedRow.organizations, {
- tenantId,
- transaction: context.transaction,
+ organizationId = await resolveWorkbookReferenceOrDb({
+ context,
+ 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;
if (reference) {
- const existingPropertyId = await resolvePropertyReference(reference, {
- currentUser: context.currentUser,
- organizationId,
- tenantId,
- transaction: context.transaction,
+ const existingPropertyId = await resolveWorkbookReferenceOrDb({
+ context,
+ entityKey: 'properties',
+ label: 'Property',
+ reference,
+ scope: { tenantId, organizationId },
+ resolver: () =>
+ resolveExistingReferenceOrNull(() =>
+ resolvePropertyReference(reference, {
+ currentUser: context.currentUser,
+ organizationId,
+ tenantId,
+ transaction: context.transaction,
+ }),
+ ),
});
if (existingPropertyId) {
+ registerWorkbookReference(context, 'properties', {
+ id: existingPropertyId,
+ references: [normalizedRow.id, normalizedRow.code, normalizedRow.name],
+ tenantId,
+ organizationId,
+ });
context.summary.properties.reused += 1;
return existingPropertyId;
}
@@ -471,15 +753,29 @@ const getOrCreatePropertyId = async (row, context) => {
transaction: context.transaction,
});
+ registerWorkbookReference(context, 'properties', {
+ id: createdProperty.id,
+ references: [normalizedRow.id, normalizedRow.code, normalizedRow.name],
+ tenantId,
+ organizationId,
+ });
+
context.summary.properties.created += 1;
return createdProperty.id;
};
const getOrCreateUnitTypeId = async (row, context) => {
const normalizedRow = normalizeInventoryImportRow(row);
- const propertyId = await resolvePropertyReference(normalizedRow.property, {
- currentUser: context.currentUser,
- transaction: context.transaction,
+ const propertyId = await resolveWorkbookReferenceOrDb({
+ context,
+ entityKey: 'properties',
+ label: 'Property',
+ reference: normalizedRow.property,
+ resolver: () =>
+ resolvePropertyReference(normalizedRow.property, {
+ currentUser: context.currentUser,
+ transaction: context.transaction,
+ }),
});
if (!propertyId) {
@@ -490,14 +786,33 @@ const getOrCreateUnitTypeId = async (row, context) => {
const reference = normalizedRow.id || normalizedRow.code || normalizedRow.name;
if (reference) {
- const existingUnitTypeId = await resolveUnitTypeReference(reference, {
- currentUser: context.currentUser,
- organizationId: property?.organizationsId || null,
- propertyId,
- transaction: context.transaction,
+ const existingUnitTypeId = await resolveWorkbookReferenceOrDb({
+ context,
+ entityKey: 'unit_types',
+ label: 'Unit type',
+ reference,
+ scope: {
+ organizationId: property?.organizationsId || null,
+ propertyId,
+ },
+ resolver: () =>
+ resolveExistingReferenceOrNull(() =>
+ resolveUnitTypeReference(reference, {
+ currentUser: context.currentUser,
+ organizationId: property?.organizationsId || null,
+ propertyId,
+ transaction: context.transaction,
+ }),
+ ),
});
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;
return existingUnitTypeId;
}
@@ -515,6 +830,13 @@ const getOrCreateUnitTypeId = async (row, context) => {
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;
return createdUnitType.id;
};
@@ -559,11 +881,209 @@ const resolveExistingUnit = async ({ id, unitNumber, propertyId, transaction })
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 normalizedRow = normalizeInventoryImportRow(row);
- const propertyId = await resolvePropertyReference(normalizedRow.property, {
- currentUser: context.currentUser,
- transaction: context.transaction,
+ const propertyId = await resolveWorkbookReferenceOrDb({
+ context,
+ entityKey: 'properties',
+ label: 'Property',
+ reference: normalizedRow.property,
+ resolver: () =>
+ resolvePropertyReference(normalizedRow.property, {
+ currentUser: context.currentUser,
+ transaction: context.transaction,
+ }),
});
if (!propertyId) {
@@ -571,11 +1091,22 @@ const getOrCreateUnitId = async (row, context) => {
}
const property = await loadPropertyContext(propertyId, context.transaction);
- const unitTypeId = await resolveUnitTypeReference(normalizedRow.unit_type, {
- currentUser: context.currentUser,
- organizationId: property?.organizationsId || null,
- propertyId,
- transaction: context.transaction,
+ const unitTypeId = await resolveWorkbookReferenceOrDb({
+ context,
+ entityKey: 'unit_types',
+ label: 'Unit type',
+ 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) {
@@ -641,6 +1172,7 @@ module.exports = class PortfolioWorkbookImportService {
properties: { created: 0, reused: 0 },
unit_types: { created: 0, reused: 0 },
units: { created: 0, reused: 0 },
+ negotiated_rates: { created: 0, reused: 0 },
};
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();
return {
diff --git a/frontend/src/helpers/portfolioWorkbookSheets.ts b/frontend/src/helpers/portfolioWorkbookSheets.ts
index 2e5050e..fdacf0a 100644
--- a/frontend/src/helpers/portfolioWorkbookSheets.ts
+++ b/frontend/src/helpers/portfolioWorkbookSheets.ts
@@ -100,4 +100,26 @@ export const portfolioWorkbookSheets: PortfolioWorkbookSheet[] = [
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',
+ },
+ },
];
diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts
index 6199888..7ba8b92 100644
--- a/frontend/src/menuAside.ts
+++ b/frontend/src/menuAside.ts
@@ -92,6 +92,7 @@ export const buildMenuAside = (currentUser?: any): MenuAsideItem[] => {
'CREATE_PROPERTIES',
'CREATE_UNIT_TYPES',
'CREATE_UNITS',
+ 'CREATE_NEGOTIATED_RATES',
],
menu: [
{
@@ -104,6 +105,7 @@ export const buildMenuAside = (currentUser?: any): MenuAsideItem[] => {
'CREATE_PROPERTIES',
'CREATE_UNIT_TYPES',
'CREATE_UNITS',
+ 'CREATE_NEGOTIATED_RATES',
],
},
{
diff --git a/frontend/src/pages/portfolio-import.tsx b/frontend/src/pages/portfolio-import.tsx
index d6d299e..90a6bff 100644
--- a/frontend/src/pages/portfolio-import.tsx
+++ b/frontend/src/pages/portfolio-import.tsx
@@ -34,6 +34,7 @@ const createPermissions = [
'CREATE_PROPERTIES',
'CREATE_UNIT_TYPES',
'CREATE_UNITS',
+ 'CREATE_NEGOTIATED_RATES',
];
const summaryLabels: Record = {
@@ -42,6 +43,7 @@ const summaryLabels: Record = {
properties: 'Properties',
unit_types: 'Unit Types',
units: 'Units',
+ negotiated_rates: 'Negotiated Rates',
};
const PortfolioImportPage = () => {
@@ -136,11 +138,11 @@ const PortfolioImportPage = () => {
One workbook for portfolio onboarding
- 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.
- 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.
@@ -193,7 +195,7 @@ const PortfolioImportPage = () => {
{importResult.workbook.sheets.map((sheet) => `${sheet.name} (${sheet.rows})`).join(', ')}
-
+
{workbookSummary.map(([key, value]) => (
{summaryLabels[key] || key}