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'],
|
||||
},
|
||||
{
|
||||
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, {
|
||||
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, {
|
||||
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, {
|
||||
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, {
|
||||
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, {
|
||||
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, {
|
||||
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, {
|
||||
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 {
|
||||
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@ -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',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -34,6 +34,7 @@ const createPermissions = [
|
||||
'CREATE_PROPERTIES',
|
||||
'CREATE_UNIT_TYPES',
|
||||
'CREATE_UNITS',
|
||||
'CREATE_NEGOTIATED_RATES',
|
||||
];
|
||||
|
||||
const summaryLabels: Record<string, string> = {
|
||||
@ -42,6 +43,7 @@ const summaryLabels: Record<string, string> = {
|
||||
properties: 'Properties',
|
||||
unit_types: 'Unit Types',
|
||||
units: 'Units',
|
||||
negotiated_rates: 'Negotiated Rates',
|
||||
};
|
||||
|
||||
const PortfolioImportPage = () => {
|
||||
@ -136,11 +138,11 @@ const PortfolioImportPage = () => {
|
||||
<div>
|
||||
<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'>
|
||||
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.
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -193,7 +195,7 @@ const PortfolioImportPage = () => {
|
||||
{importResult.workbook.sheets.map((sheet) => `${sheet.name} (${sheet.rows})`).join(', ')}
|
||||
</p>
|
||||
</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]) => (
|
||||
<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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user