feat(product): price standalone products from the cover alone
build / build (push) Successful in 8m52s

Recovered work in progress from 2026-05. A product-cover can be marked
standalone, and a standalone product (Journaling Booklet) prices as the
cover price without the pattern/pages/ruling lookups, which do not exist
for it. The remaining component lookups are now guarded so a missing
relation yields null instead of calling findOne with undefined.
This commit is contained in:
2026-09-23 22:10:06 +02:00
parent 7f7cacc448
commit 901a3b385f
5 changed files with 54 additions and 6 deletions
@@ -54,6 +54,10 @@
"price": { "price": {
"type": "decimal" "type": "decimal"
}, },
"standalone": {
"type": "boolean",
"default": false
},
"products": { "products": {
"type": "relation", "type": "relation",
"relation": "oneToMany", "relation": "oneToMany",
@@ -11,10 +11,26 @@ export default {
strapi.log.verbose("app:v:product-lifecycle: Before create", { strapi.log.verbose("app:v:product-lifecycle: Before create", {
params params
}); });
const cover = await strapi.entityService.findOne("api::product-cover.product-cover", params.data?.cover, { fields: ["price"] }); const cover = params.data?.cover
const pattern = await strapi.entityService.findOne("api::product-pattern.product-pattern", params.data?.pattern); ? await strapi.entityService.findOne("api::product-cover.product-cover", params.data.cover, { fields: ["price", "standalone"] })
const pages = await strapi.entityService.findOne("api::product-page.product-page", params.data?.pages, { fields: ["price"] }); : null;
const ruling = await strapi.entityService.findOne("api::product-ruling.product-ruling", params.data?.ruling, { fields: ["price"] });
// Standalone products (e.g. Journaling Booklet) have no pattern/pages/ruling.
// Price is the cover price alone — skip combinatorial component lookups.
if (params.data?.standalone || (cover as any)?.standalone) {
params.data.totalPrice = cover?.price ?? 0;
return;
}
const pattern = params.data?.pattern
? await strapi.entityService.findOne("api::product-pattern.product-pattern", params.data.pattern)
: null;
const pages = params.data?.pages
? await strapi.entityService.findOne("api::product-page.product-page", params.data.pages, { fields: ["price"] })
: null;
const ruling = params.data?.ruling
? await strapi.entityService.findOne("api::product-ruling.product-ruling", params.data.ruling, { fields: ["price"] })
: null;
const product = { const product = {
...params.data, ...params.data,
cover, cover,
+18 -1
View File
@@ -108,6 +108,12 @@ export default factories.createCoreController("api::product.product", ({ strapi
strapi.log.verbose(`Fetching variants for product ${id}`); strapi.log.verbose(`Fetching variants for product ${id}`);
const product = await getProductDetails(id as ID); const product = await getProductDetails(id as ID);
// Standalone products (e.g. Journaling Booklet) have no pattern — no variants.
if (!product.pattern?.id) {
return [];
}
const variants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>("api::product.product", { const variants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>("api::product.product", {
...productVariantParams, ...productVariantParams,
filters: { filters: {
@@ -146,6 +152,11 @@ export default factories.createCoreController("api::product.product", ({ strapi
const product = await getProductDetails(id as ID); const product = await getProductDetails(id as ID);
const { pattern, cover, ruling, pages } = product; const { pattern, cover, ruling, pages } = product;
// Standalone products (e.g. Journaling Booklet) have no pattern → no variants.
if (!pattern?.id) {
return { allProductPattern: [], productVariants: [], patterns: [] };
}
const productVariants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>("api::product.product", { const productVariants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>("api::product.product", {
...productVariantParams, ...productVariantParams,
filters: { filters: {
@@ -203,6 +214,11 @@ export default factories.createCoreController("api::product.product", ({ strapi
const product = await getProductDetails(id as ID); const product = await getProductDetails(id as ID);
// Standalone products (e.g. Journaling Booklet) have no pattern → no variant groups.
if (!product.pattern?.id) {
return { pages: [], cover: [], ruling: [] };
}
const allProductPatternVariants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>( const allProductPatternVariants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>(
"api::product.product", "api::product.product",
{ {
@@ -335,7 +351,8 @@ export default factories.createCoreController("api::product.product", ({ strapi
const productGroups = new Map(); const productGroups = new Map();
allProductsSanitized.forEach((product) => { allProductsSanitized.forEach((product) => {
const key = `${product.cover.id}-${product.pattern.id}`; // Standalone products (e.g. Journaling Booklet) have no pattern — never grouped.
const key = product.pattern?.id ? `${product.cover?.id}-${product.pattern.id}` : `standalone-${product.id}`;
if (!productGroups.has(key) || calculateTotalProductPrice(product) < calculateTotalProductPrice(productGroups.get(key))) { if (!productGroups.has(key) || calculateTotalProductPrice(product) < calculateTotalProductPrice(productGroups.get(key))) {
productGroups.set(key, product); productGroups.set(key, product);
} }
+11 -1
View File
@@ -110,8 +110,12 @@ async function generateNewProducts(strapi: Strapi, componentType: string, compon
strapi.log.debug(`app:d:lifecycle-factory ${JSON.stringify({ covers, patterns, pages, rulings })}`); strapi.log.debug(`app:d:lifecycle-factory ${JSON.stringify({ covers, patterns, pages, rulings })}`);
// Standalone covers (e.g. the Journaling Booklet) are not combinatorial —
// their product is created manually and must never be fanned out.
const sellableCovers = covers.filter((cover) => cover && !(cover as ProductCover).standalone);
// Generate new combinations // Generate new combinations
for (const cover of covers) { for (const cover of sellableCovers) {
for (const pattern of patterns) { for (const pattern of patterns) {
for (const page of pages) { for (const page of pages) {
for (const ruling of rulings) { for (const ruling of rulings) {
@@ -181,6 +185,12 @@ async function updateRelatedProducts(strapi: Strapi, componentType: string, comp
for (const product of products) { for (const product of products) {
try { try {
// Standalone products (cover-only, e.g. Journaling Booklet) are not
// factory-managed — never rename/reslug/delete them.
if (!product.pattern?.id) {
continue;
}
// Get the current components for this product // Get the current components for this product
const cover = product.cover?.id; const cover = product.cover?.id;
const pattern = product.pattern?.id; const pattern = product.pattern?.id;
+1
View File
@@ -453,6 +453,7 @@ export interface ApiProductCoverProductCover extends Schema.CollectionType {
number number
> & > &
Attribute.DefaultTo<0>; Attribute.DefaultTo<0>;
standalone: Attribute.Boolean & Attribute.DefaultTo<false>;
updatedAt: Attribute.DateTime; updatedAt: Attribute.DateTime;
updatedBy: Attribute.Relation<"api::product-cover.product-cover", "oneToOne", "admin::user"> & Attribute.Private; updatedBy: Attribute.Relation<"api::product-cover.product-cover", "oneToOne", "admin::user"> & Attribute.Private;
}; };