Fix customer additional-service restrictions (#171)
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
# Customer attributes refactor and migration plan
|
||||
|
||||
## Problem statement
|
||||
|
||||
Customer attributes are currently represented as loosely typed string flags and evaluated in several UI, POS, and invoicing paths. This makes product restrictions vulnerable to broad category heuristics. The immediate defect is that `restrictAdditionalServices` ("Begræns tillægsydelser") treats related booking add-ons as additional services, so interior wash add-ons plus trailer/dolly additions are blocked even though that attribute is intended to cover standalone additional services only.
|
||||
|
||||
## Target behavior matrix
|
||||
|
||||
| Attribute | Canonical intent | Product availability behavior | Invoice/workflow behavior |
|
||||
| ------------------------------------ | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `restrictAdditionalServices` | Block standalone additional services/tillægsydelser. | Block standalone additional-service catalog items; do not block related booking add-ons such as interior wash, trailer, or dolly. | Flag only order lines that are standalone additional services. |
|
||||
| `restrictTankCleaning` | Block tank-cleaning services. | Block products whose category or legacy name identifies tank cleaning. | Flag tank-cleaning order lines. |
|
||||
| `restrictSpotFree` | Block Spot Free/RO rinse products. | Block canonical Spot Free product IDs and legacy Spot Free/RO naming. | Flag Spot Free order lines. |
|
||||
| `restrictInteriorCleaning` | Block interior wash services. | Block products whose names/categories explicitly identify interior wash. | Flag interior-wash order lines. |
|
||||
| `onlyTankCleaning` | Allow only tank-cleaning services. | Block every non-tank-cleaning product while keeping tank-cleaning products available. | Flag non-tank-cleaning order lines. |
|
||||
| `requiresReferenceNumber` | Require an order reference. | No product filtering. | Flag orders missing a required reference. |
|
||||
| `requiresRegistrationNumbersInvoice` | Require registration numbers on invoice/order context. | No product filtering. | Flag orders missing required registration numbers. |
|
||||
| `invoiceAllOrdersIndividually` | Prevent grouped invoicing. | No product filtering. | Split/flag invoice collections containing multiple orders for the customer. |
|
||||
| `invoiceWithStripe` | Invoice through Stripe workflow. | No product filtering. | Route the customer through Stripe invoicing/payment handling. |
|
||||
| `showPricesOnBookingPage` | Show customer prices during booking. | No product filtering. | Presentation-only booking behavior. |
|
||||
| `usePONumbers` | Use/prompt for PO numbers. | No product filtering. | Require or expose PO-number workflow where configured. |
|
||||
| `exemptFromAdministrationFee` | Do not charge administration fees. | No product filtering. | Suppress/flag administration-fee order lines for this customer. |
|
||||
|
||||
## Refactor plan
|
||||
|
||||
1. **Create a canonical customer-rule domain module**
|
||||
|
||||
- Keep `CUSTOMER_RULE_DEFINITIONS` as the registry of public attributes, but extend each entry with a typed evaluator contract: product predicate, category predicate, invoice predicate, and UI impact metadata.
|
||||
- Replace scattered string comparisons with registry lookups so every surface uses the same semantics.
|
||||
- Add explicit names for ambiguous categories: `standaloneAdditionalService`, `relatedAddon`, `primaryProduct`, `tankCleaning`, `spotFree`, and `interiorCleaning`.
|
||||
|
||||
2. **Normalize product classification once**
|
||||
|
||||
- Build a `classifyCustomerRuleProduct(product, context)` helper returning booleans for each product class.
|
||||
- Treat related add-ons (`isRelatedAddon`, `relatedItemId`) as context, not as proof that the item is a standalone additional service.
|
||||
- Reserve `restrictAdditionalServices` for category 8/standalone service context or explicit additional-service labels, not numeric booking add-on category 4.
|
||||
|
||||
3. **Migrate rule evaluation paths**
|
||||
|
||||
- POS product cards and mobile flows should call `getCustomerProductRestriction` only with the normalized product context.
|
||||
- Customer-rule tooltips should derive blocked/available products from the same evaluator used by POS.
|
||||
- Invoicing-period flag generation should use the same classification vocabulary as product availability so historical and current orders are flagged consistently.
|
||||
|
||||
4. **Backfill and data migration**
|
||||
|
||||
- Keep existing attribute keys unchanged to avoid a destructive migration.
|
||||
- Add a one-time data audit/report listing customers with `restrictAdditionalServices` and recent orders containing interior wash, trailer, or dolly add-ons. These rows should be verified as no longer violating the rule after deployment.
|
||||
- If any historical invoice flags were created solely because related add-ons were treated as additional services, provide an idempotent cleanup command to recalculate customer-rule violations for affected invoice periods.
|
||||
|
||||
5. **Regression test coverage**
|
||||
|
||||
- Unit-test every attribute in the target behavior matrix.
|
||||
- Add focused cases for the defect: interior wash related add-on, trailer related add-on, and dolly related add-on must remain available under `restrictAdditionalServices`.
|
||||
- Add invoice-flag fixtures mirroring the same products so invoicing behavior cannot drift from POS behavior.
|
||||
- Keep tooltip tests aligned with the evaluator, showing standalone additional services under `restrictAdditionalServices` and not showing related add-ons.
|
||||
|
||||
6. **Rollout and verification**
|
||||
- Ship the evaluator patch behind the existing attribute keys.
|
||||
- Run unit tests and targeted POS/customer-rule e2e tests.
|
||||
- Verify with production-like catalog data that `restrictAdditionalServices` blocks only standalone additional services while `restrictInteriorCleaning`, `restrictTankCleaning`, `restrictSpotFree`, and `onlyTankCleaning` continue to behave exactly as listed above.
|
||||
@@ -1,7 +1,7 @@
|
||||
const ADDON_CATEGORY_ID = 4;
|
||||
const STANDALONE_ADDITIONAL_SERVICE_CATEGORY_ID = 8;
|
||||
const TANK_CLEANING_TERMS = ["tank cleaning", "tankcleaning", "tankrens", "tank rens"];
|
||||
const ADDITIONAL_SERVICE_TERMS = ["add-on", "add on", "addon", "tilvalg"];
|
||||
const ADDITIONAL_SERVICE_TERMS = ["additional service", "additional services", "tillægsydelse", "tillægsydelser"];
|
||||
const SPOT_FREE_PRODUCT_IDS = [23, 24];
|
||||
const SPOT_FREE_TERMS = ["spot free", "spotfree", "skylning med ro"];
|
||||
|
||||
@@ -41,7 +41,10 @@ export const hasCustomerAttribute = (attributes, attributeName) => {
|
||||
return attributes.some((attribute) => normalizeAttributeName(attribute) === attributeName);
|
||||
};
|
||||
|
||||
const normalizedText = (value) => String(value ?? "").trim().toLowerCase();
|
||||
const normalizedText = (value) =>
|
||||
String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const textContainsAny = (value, terms) => {
|
||||
const text = normalizedText(value);
|
||||
@@ -57,7 +60,11 @@ export const isTankCleaningCategory = (category) => {
|
||||
};
|
||||
|
||||
export const isAddonCategory = (category, categoryName = null, options = {}) => {
|
||||
if (String(category ?? "").trim().toLowerCase() === "addons") {
|
||||
if (
|
||||
String(category ?? "")
|
||||
.trim()
|
||||
.toLowerCase() === "addons"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -78,14 +85,12 @@ export const isStandaloneAdditionalServiceCatalogProduct = (product) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isAddonCategory(category, product.category_name ?? product.categoryName) || textContainsAny(
|
||||
[
|
||||
product.name,
|
||||
product.product_name,
|
||||
product.category_name,
|
||||
product.categoryName,
|
||||
].join(" "),
|
||||
ADDITIONAL_SERVICE_TERMS
|
||||
return (
|
||||
isAddonCategory(category, product.category_name ?? product.categoryName) ||
|
||||
textContainsAny(
|
||||
[product.name, product.product_name, product.category_name, product.categoryName].join(" "),
|
||||
ADDITIONAL_SERVICE_TERMS
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -99,12 +104,7 @@ export const isTankCleaningProduct = (product) => {
|
||||
}
|
||||
|
||||
return textContainsAny(
|
||||
[
|
||||
product.name,
|
||||
product.product_name,
|
||||
product.category_name,
|
||||
product.categoryName,
|
||||
].join(" "),
|
||||
[product.name, product.product_name, product.category_name, product.categoryName].join(" "),
|
||||
TANK_CLEANING_TERMS
|
||||
);
|
||||
};
|
||||
@@ -120,12 +120,7 @@ export const isSpotFreeProduct = (product) => {
|
||||
}
|
||||
|
||||
return textContainsAny(
|
||||
[
|
||||
product.name,
|
||||
product.product_name,
|
||||
product.category_name,
|
||||
product.categoryName,
|
||||
].join(" "),
|
||||
[product.name, product.product_name, product.category_name, product.categoryName].join(" "),
|
||||
SPOT_FREE_TERMS
|
||||
);
|
||||
};
|
||||
@@ -158,28 +153,13 @@ export const isProductCategoryRestrictedForCustomer = (category, attributes = []
|
||||
};
|
||||
|
||||
export const isAdditionalServiceProduct = (product, options = {}) => {
|
||||
if (options.isRelatedAddon === true || Number(options.relatedItemId ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.isStandaloneAdditionalService === true || options.hasExistingStandaloneOrderItem === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const category = product?.category ?? product?.product_category;
|
||||
const categoryName = product?.category_name ?? product?.categoryName ?? options.categoryName;
|
||||
if (isAddonCategory(category, categoryName, options)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return textContainsAny(
|
||||
[
|
||||
product?.name,
|
||||
product?.product_name,
|
||||
categoryName,
|
||||
].join(" "),
|
||||
ADDITIONAL_SERVICE_TERMS
|
||||
);
|
||||
return textContainsAny([product?.name, product?.product_name, categoryName].join(" "), ADDITIONAL_SERVICE_TERMS);
|
||||
};
|
||||
|
||||
export const getCustomerProductRestriction = (product, attributes = [], options = {}) => {
|
||||
@@ -189,10 +169,7 @@ export const getCustomerProductRestriction = (product, attributes = [], options
|
||||
|
||||
const productName = normalizedText(product.name ?? product.product_name);
|
||||
|
||||
if (
|
||||
hasCustomerAttribute(attributes, "restrictAdditionalServices") &&
|
||||
isAdditionalServiceProduct(product, options)
|
||||
) {
|
||||
if (hasCustomerAttribute(attributes, "restrictAdditionalServices") && isAdditionalServiceProduct(product, options)) {
|
||||
return restrictedByRule("restrictAdditionalServices");
|
||||
}
|
||||
if (hasCustomerAttribute(attributes, "restrictSpotFree") && isSpotFreeProduct(product)) {
|
||||
|
||||
@@ -44,16 +44,15 @@ export const normalizeRuleProductList = (products = []) => {
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
return sortProductsByDisplayOrder(products)
|
||||
.filter((product) => {
|
||||
const productId = normalizeProductId(product?.id ?? product?.product_id);
|
||||
if (productId === null || seen.has(productId)) {
|
||||
return false;
|
||||
}
|
||||
return sortProductsByDisplayOrder(products).filter((product) => {
|
||||
const productId = normalizeProductId(product?.id ?? product?.product_id);
|
||||
if (productId === null || seen.has(productId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(productId);
|
||||
return normalizeProductName(product) !== "";
|
||||
});
|
||||
seen.add(productId);
|
||||
return normalizeProductName(product) !== "";
|
||||
});
|
||||
};
|
||||
|
||||
const addonToProduct = (addon, parentProduct) => {
|
||||
@@ -72,17 +71,14 @@ const addonToProduct = (addon, parentProduct) => {
|
||||
};
|
||||
|
||||
const flattenRelatedAddons = (products) =>
|
||||
products.flatMap((product) => (
|
||||
Array.isArray(product?.addons)
|
||||
? product.addons.map((addon) => addonToProduct(addon, product))
|
||||
: []
|
||||
));
|
||||
products.flatMap((product) =>
|
||||
Array.isArray(product?.addons) ? product.addons.map((addon) => addonToProduct(addon, product)) : []
|
||||
);
|
||||
|
||||
const buildAttributeSet = (attribute) => [{ attribute }];
|
||||
|
||||
const productIsRestrictedByAttribute = (product, attribute, options = {}) => (
|
||||
getCustomerProductRestriction(product, buildAttributeSet(attribute), options).rule === attribute
|
||||
);
|
||||
const productIsRestrictedByAttribute = (product, attribute, options = {}) =>
|
||||
getCustomerProductRestriction(product, buildAttributeSet(attribute), options).rule === attribute;
|
||||
|
||||
const emptyGroups = () => ({
|
||||
primaryProducts: [],
|
||||
@@ -99,10 +95,15 @@ const groupedRestrictedProducts = (attribute, products) => {
|
||||
|
||||
return {
|
||||
primaryProducts: normalizedProducts.filter((product) => productIsRestrictedByAttribute(product, attribute)),
|
||||
relatedAddons: relatedAddons.filter((product) => productIsRestrictedByAttribute(product, attribute, {
|
||||
includeNumericAddonCategory: true,
|
||||
isRelatedAddon: true,
|
||||
})),
|
||||
relatedAddons:
|
||||
attribute === "restrictAdditionalServices"
|
||||
? []
|
||||
: relatedAddons.filter((product) =>
|
||||
productIsRestrictedByAttribute(product, attribute, {
|
||||
includeNumericAddonCategory: true,
|
||||
isRelatedAddon: true,
|
||||
})
|
||||
),
|
||||
standaloneAdditionalServices: standaloneCandidates.filter((product) =>
|
||||
productIsRestrictedByAttribute(product, attribute, {
|
||||
includeNumericAddonCategory: true,
|
||||
@@ -114,7 +115,9 @@ const groupedRestrictedProducts = (attribute, products) => {
|
||||
|
||||
const groupedAllowedOnlyTankCleaningProducts = (products) => ({
|
||||
primaryProducts: normalizeRuleProductList(products).filter((product) => isTankCleaningProduct(product)),
|
||||
relatedAddons: normalizeRuleProductList(flattenRelatedAddons(products)).filter((product) => isTankCleaningProduct(product)),
|
||||
relatedAddons: normalizeRuleProductList(flattenRelatedAddons(products)).filter((product) =>
|
||||
isTankCleaningProduct(product)
|
||||
),
|
||||
standaloneAdditionalServices: [],
|
||||
});
|
||||
|
||||
@@ -130,9 +133,8 @@ export const getCustomerRuleProductImpact = (attribute, products = []) => {
|
||||
|
||||
const normalizedProducts = normalizeRuleProductList(products);
|
||||
const blocked = groupedRestrictedProducts(attribute, normalizedProducts);
|
||||
const available = attribute === "onlyTankCleaning"
|
||||
? groupedAllowedOnlyTankCleaningProducts(normalizedProducts)
|
||||
: emptyGroups();
|
||||
const available =
|
||||
attribute === "onlyTankCleaning" ? groupedAllowedOnlyTankCleaningProducts(normalizedProducts) : emptyGroups();
|
||||
|
||||
return {
|
||||
hasProductImpact: true,
|
||||
@@ -142,9 +144,8 @@ export const getCustomerRuleProductImpact = (attribute, products = []) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const groupHasProducts = (groupedProducts = {}) => (
|
||||
PRODUCT_GROUPS.some((group) => (groupedProducts[group.key] || []).length > 0)
|
||||
);
|
||||
export const groupHasProducts = (groupedProducts = {}) =>
|
||||
PRODUCT_GROUPS.some((group) => (groupedProducts[group.key] || []).length > 0);
|
||||
|
||||
export const getCustomerRuleTooltipModel = (attribute, options = {}) => {
|
||||
const definition = getCustomerRuleDefinition(attribute);
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("customer product rules", () => {
|
||||
expect(isProductCategoryRestrictedForCustomer("tank_cleaning", restrictTankCleaning)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns restriction metadata for related add-ons when additional services are restricted", () => {
|
||||
it("does not restrict related interior wash add-ons when additional services are restricted", () => {
|
||||
const restriction = getCustomerProductRestriction(
|
||||
{ id: 63, category: 4, name: "Indvendig vask Forvogn" },
|
||||
restrictAdditionalServices,
|
||||
@@ -52,9 +52,9 @@ describe("customer product rules", () => {
|
||||
);
|
||||
|
||||
expect(restriction).toEqual({
|
||||
restricted: true,
|
||||
rule: "restrictAdditionalServices",
|
||||
messageKey: "pos.restrictions.addons_not_allowed",
|
||||
restricted: false,
|
||||
rule: null,
|
||||
messageKey: null,
|
||||
});
|
||||
expect(
|
||||
isProductRestrictedForCustomer(
|
||||
@@ -62,10 +62,10 @@ describe("customer product rules", () => {
|
||||
restrictAdditionalServices,
|
||||
{ isRelatedAddon: true }
|
||||
)
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks add-on category products in explicit additional-service context", () => {
|
||||
it("does not treat booking add-on categories as standalone additional services", () => {
|
||||
expect(
|
||||
isProductCategoryRestrictedForCustomer(4, restrictAdditionalServices, { includeNumericAddonCategory: true })
|
||||
).toBe(true);
|
||||
@@ -82,6 +82,32 @@ describe("customer product rules", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps trailer, dolly, and interior wash add-ons available under additional-service restrictions", () => {
|
||||
const relatedAddonOptions = { isRelatedAddon: true, relatedItemId: 10 };
|
||||
|
||||
expect(
|
||||
isProductRestrictedForCustomer(
|
||||
{ id: 62, category: 4, name: "Indvendig vask Forvogn" },
|
||||
restrictAdditionalServices,
|
||||
relatedAddonOptions
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isProductRestrictedForCustomer(
|
||||
{ id: 63, category: 4, name: "Dolly" },
|
||||
restrictAdditionalServices,
|
||||
relatedAddonOptions
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isProductRestrictedForCustomer(
|
||||
{ id: 92, category: 4, name: "Trailer" },
|
||||
restrictAdditionalServices,
|
||||
relatedAddonOptions
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("describes exact products blocked by spot-free restrictions", () => {
|
||||
const impact = getCustomerRuleProductImpact("restrictSpotFree", [
|
||||
{ id: 23, category: 4, name: "Spot free rinse" },
|
||||
@@ -114,7 +140,7 @@ describe("customer product rules", () => {
|
||||
]);
|
||||
|
||||
expect(impact.blocked.primaryProducts).toEqual([]);
|
||||
expect(impact.blocked.relatedAddons.map((product) => product.name)).toEqual(["Interior add-on"]);
|
||||
expect(impact.blocked.relatedAddons).toEqual([]);
|
||||
expect(impact.blocked.standaloneAdditionalServices.map((product) => product.name)).toEqual(["Extra detergent"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("CustomerRuleTooltip", () => {
|
||||
it("renders the rule change and exact additional-service product groups", () => {
|
||||
it("renders the rule change and exact standalone additional-service product groups", () => {
|
||||
const wrapper = mountTooltip({
|
||||
active: true,
|
||||
attribute: "restrictAdditionalServices",
|
||||
@@ -114,13 +114,7 @@ describe("CustomerRuleTooltip", () => {
|
||||
expect(content.text()).toContain("Blocks additional services.");
|
||||
expect(content.text()).toContain("Blocked while active");
|
||||
expect(content.text()).not.toContain("Truck wash");
|
||||
expect(wrapper.get('[data-testid="rule-tooltip-blocked-relatedAddons"]').text()).toContain("Interior add-on");
|
||||
expect(
|
||||
wrapper.get('[data-testid="rule-tooltip-blocked-relatedAddons"] .customer-rule-tooltip__blocked-prefix').text()
|
||||
).toBe("-");
|
||||
expect(wrapper.get('[data-testid="rule-tooltip-blocked-relatedAddons"] li').classes()).toContain(
|
||||
"customer-rule-tooltip__blocked-product"
|
||||
);
|
||||
expect(wrapper.find('[data-testid="rule-tooltip-blocked-relatedAddons"]').exists()).toBe(false);
|
||||
expect(wrapper.get('[data-testid="rule-tooltip-blocked-standaloneAdditionalServices"]').text()).toContain(
|
||||
"Extra detergent"
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user