From 611b7842c0633d119f42558103a2d226fcefcd89 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 3 Jul 2026 13:26:59 +0800 Subject: [PATCH] feat(external): expose development owners in catalog wide view --- .../workflow/外部只读数据库开放操作说明.md | 10 +- .../docs/架构设计/外部只读契约巡检说明.md | 5 +- .../docs/架构设计/外部只读视图字段字典-v1.md | 18 +- .../数据库主数据治理与外部只读契约.md | 15 +- .../migration.sql | 390 ++++++++++++++ .../migration.sql | 510 ++++++++++++++++++ .../sql/check-external-readonly-contract.sql | 27 +- 7 files changed, 958 insertions(+), 17 deletions(-) create mode 100644 furniture-3d-system/prisma/migrations/20260702143000_normalize_external_readonly_asset_urls/migration.sql create mode 100644 furniture-3d-system/prisma/migrations/20260703110000_add_development_people_to_external_catalog_wide/migration.sql diff --git a/furniture-3d-system/docs/workflow/外部只读数据库开放操作说明.md b/furniture-3d-system/docs/workflow/外部只读数据库开放操作说明.md index d68c0d27..71b9c621 100644 --- a/furniture-3d-system/docs/workflow/外部只读数据库开放操作说明.md +++ b/furniture-3d-system/docs/workflow/外部只读数据库开放操作说明.md @@ -21,13 +21,16 @@ SELECT * FROM external.product_catalog_wide_v1; ``` -这张视图是一 SKU 一行。正式 `Product / ProductSKU` 已存在时输出正式商品行;正式商品尚未落库但 non-integrated 已进入 ready 状态时,补输出可消费的开发阶段 SKU 行。外部系统按 `barcode_business_key` 做主同步。 +这张视图是一条码一行。正式 `Product / ProductSKU` 已存在时输出正式条码商品行;正式商品尚未落库但 non-integrated 已进入 ready 状态时,补输出可消费的开发阶段条码行。外部系统必须按 `barcode_business_key` 做主同步、去重和幂等更新;同一 SKU 下多个条码会输出多行。 补充口径: - non-integrated ready 行会带上主图、开发阶段限价和基础商品字段。 +- non-integrated ready 行会带上产品开发明细页的负责人字段:外观设计师、结构设计师、SPU 负责人、商品企划、空间设计师、采购负责人;正式商品行没有开发阶段上下文时这些列保持空值。 - non-integrated ready 行尚未正式化时,正式 BOM、包装库存、仓库库位和 SKU 质检摘要字段会保持 `0 / NULL / []`。 - 需要核对开发阶段头信息或 SKU 明细真值时,继续读取 `external.intake_products_ready_v1 / external.intake_sku_items_ready_v1`。 +- 图片、模型、贴图等 URL 字段在外部视图中输出为完整系统入口,例如 `https://cp.jiansun.vip/api/storage/object?key=...`。这些入口仍由应用侧鉴权和对象存储跳转控制,不把私有桶短期签名 URL 固化进数据库。 +- 非生产环境如需使用其他站点域名,可由数据库管理员设置 `ALTER DATABASE SET app.external_asset_base_url = '';`;未设置时默认使用正式站点 `https://cp.jiansun.vip`。 限价口径固定如下: @@ -39,14 +42,15 @@ FROM external.product_catalog_wide_v1; | 方向 | 推荐视图 | 用途 | | --- | --- | --- | -| 产品资料 | `external.product_catalog_wide_v1` | 一 SKU 一行的商品目录宽表,覆盖正式 SKU 与可消费的 non-integrated ready SKU | +| 产品资料 | `external.product_catalog_wide_v1` | 一条码一行的商品目录宽表,覆盖正式条码与可消费的 non-integrated ready 条码 | | 产品限价 | `external.intake_product_limits_v1` | 非集成产品限价来源明细 | -| 产品资料明细 | `external.product_assets_v1` / `external.product_bom_v1` | SKU 素材和 BOM 明细 | | 仓库管理 | `external.warehouse_locations_v1` | 库位资料 | | 仓库管理 | `external.sku_warehouse_stock_v1` | 正式 SKU 库存和库位 | | 仓库管理 | `external.packaged_items_v1` | 已关联正式 SKU 的包装库存项 | | 仓库质检 | `external.sku_quality_inspections_v1` | SKU 质检记录 | +补充说明:`external.product_assets_v1` / `external.product_bom_v1` 是外部契约巡检覆盖的明细视图,但不在当前 `external_core_reader` 核心授权包内;外部系统确实需要逐条素材或 BOM 时,必须单独评审并追加白名单授权。 + 暂不开放: - `public` schema 原表。 diff --git a/furniture-3d-system/docs/架构设计/外部只读契约巡检说明.md b/furniture-3d-system/docs/架构设计/外部只读契约巡检说明.md index ffc48600..0d072004 100644 --- a/furniture-3d-system/docs/架构设计/外部只读契约巡检说明.md +++ b/furniture-3d-system/docs/架构设计/外部只读契约巡检说明.md @@ -47,8 +47,9 @@ npx prisma db execute --file scripts/sql/check-external-readonly-contract.sql - `missing_external_column` - `external_contract_private_column_risk` - `external_view_json_source_dependency_risk` -- `external_reader_public_schema_grant_risk` -- `external_reader_non_select_grant_risk` +- `external_readonly_role_public_schema_grant_risk` +- `external_readonly_role_non_select_grant_risk` +- `external_asset_url_unstable_or_internal_risk` - `product_assets_data_url_risk` - `product_sku_business_key_duplicate_risk` - `intake_product_business_key_duplicate_risk` diff --git a/furniture-3d-system/docs/架构设计/外部只读视图字段字典-v1.md b/furniture-3d-system/docs/架构设计/外部只读视图字段字典-v1.md index f9f6a9bd..8a21a891 100644 --- a/furniture-3d-system/docs/架构设计/外部只读视图字段字典-v1.md +++ b/furniture-3d-system/docs/架构设计/外部只读视图字段字典-v1.md @@ -168,6 +168,10 @@ | `visual_planning` | 空间/视觉负责人 | | `structure_designer` | 结构设计负责人 | | `purchase_owner` | 采购负责人结构化字段 | +| `appearance_designer` | 外观设计师明确字段;来自非集成产品基础列 | +| `spu_owner` | SPU 负责人明确字段;优先来自结构化 profile,旧运营负责人只作兜底 | +| `planning_owner` | 商品企划明确字段;来自结构化 profile | +| `space_designer` | 空间设计师明确字段;优先来自结构化 profile 的空间/视觉负责人 | | `product_development_kind` | 开发类型 | | `goods_type` | 商品类型 | | `material_type` | 材料类型 | @@ -177,7 +181,7 @@ - 该视图过滤已软删除记录。 - 产品名称、图片、品类、年份、状态、开发类型、货品类型、材质类型、生产类型和结构化负责人字段来自 `IntakeNonIntegratedProductProfile`,不再直接读取 `payload`。 -- `designer / operator / purchaser` 是兼容列,优先输出 profile 中的结构设计、商品企划、采购负责人;旧产品行列只作为兼容兜底。 +- `designer / operator / purchaser` 是兼容列,优先输出 profile 中的结构设计、商品企划、采购负责人;旧产品行列只作为兼容兜底。新接入优先读取 `appearance_designer / structure_designer / spu_owner / planning_owner / space_designer / purchase_owner` 这些明确字段。 - `intake_product_id` 只用于内部追踪和问题定位;外部系统同步、去重和幂等更新必须优先使用 `product_business_key`。 - 不开放 `visibility`、`departmentId`、`teamId`、审批状态、内部确认状态。 - `external.intake_products_ready_v1` 使用同一字段结构,但只保留产品名称、品类、开发类型和 `product_business_key` 都已补齐的记录。外部系统做稳定同步时优先读取 ready 视图;`intake_products_v1` 用于治理对账和人工补齐。 @@ -211,6 +215,12 @@ | `size_json` | 尺寸 JSON | | `color` | 颜色/花色 | | `material` | 材质 | +| `appearance_designer` | 产品开发明细的外观设计师;开发阶段 ready 行来自产品资料负责人字段,正式商品行暂为空 | +| `structure_designer` | 产品开发明细的结构设计师;开发阶段 ready 行来自产品资料负责人字段,正式商品行暂为空 | +| `spu_owner` | 产品开发明细的 SPU 负责人;开发阶段 ready 行来自产品资料负责人字段,正式商品行暂为空 | +| `planning_owner` | 产品开发明细的商品企划;开发阶段 ready 行来自产品资料负责人字段,正式商品行暂为空 | +| `space_designer` | 产品开发明细的空间设计师;开发阶段 ready 行来自产品资料负责人字段,正式商品行暂为空 | +| `purchase_owner` | 产品开发明细的采购负责人;开发阶段 ready 行来自产品资料负责人字段,正式商品行暂为空 | | `warehouse_location_code` | 仓库库位编码 | | `warehouse_zone / warehouse_aisle / warehouse_shelf / warehouse_bin` | 库位物理位置 | | `warehouse_location_status` | 库位状态:`ACTIVE` / `INACTIVE` | @@ -249,9 +259,11 @@ 注意: -- 该视图保持一 SKU 一行,适合外部系统做商品目录同步。 +- 该视图保持一条码一行,适合外部系统做商品目录同步;同一 SKU 下多个条码会输出多行。 - 如果外部系统只想读一张表,优先使用该宽表;它已经覆盖正式 SKU 与可消费的 non-integrated ready SKU,以及产品资料、库存库位摘要、包装库存摘要和最新质检摘要。 -- 需要逐条素材或逐条 BOM 时,仍读取 `external.product_assets_v1 / external.product_bom_v1`。 +- 需要逐条素材或逐条 BOM 时,仍读取 `external.product_assets_v1 / external.product_bom_v1`;这两张明细视图是否对外授权,以当前 `external_core_reader` 白名单为准。 +- 所有 URL 字段都应是完整系统入口;相对的 `/api/...` 或 `/materials/...` 路径会在外部视图层按 `app.external_asset_base_url` 补全,未设置时默认补到 `https://cp.jiansun.vip`。 +- `appearance_designer / structure_designer / spu_owner / planning_owner / space_designer / purchase_owner` 是产品开发明细页红框负责人字段的外部同步口径;它们只对 non-integrated ready 行有明确来源,正式商品行没有开发阶段上下文时保持空值。 - 需要逐条包装库存或质检明细时,读取 `external.packaged_items_v1 / external.sku_quality_inspections_v1`。 - 限价口径:`存量商品明细` 的产品一般没有限价,宽表会输出 `limit_price_status = NOT_APPLICABLE`;`产品开发明细`、`模块化产品明细` 到 `产品限价` 节点确认后,限价视为正式口径并输出为 `CONFIRMED`。 - `product_id / sku_id` 只用于内部追踪;正式行会输出正式产品/SKU ID,non-integrated ready 行会输出 `intake_product_id / intake_sku_item_id`;外部主同步、去重和幂等更新必须使用 `barcode_business_key`。 diff --git a/furniture-3d-system/docs/架构设计/数据库主数据治理与外部只读契约.md b/furniture-3d-system/docs/架构设计/数据库主数据治理与外部只读契约.md index 3b29a56e..4db657e4 100644 --- a/furniture-3d-system/docs/架构设计/数据库主数据治理与外部只读契约.md +++ b/furniture-3d-system/docs/架构设计/数据库主数据治理与外部只读契约.md @@ -214,7 +214,7 @@ CREATE SCHEMA IF NOT EXISTS external; | 视图 | 用途 | | --- | --- | | `external.product_skus_v1` | 正式产品 SKU 主数据 | -| `external.product_catalog_wide_v1` | 商品目录聚合宽表,覆盖正式 SKU 与可消费的 non-integrated ready SKU,以及产品资料、限价、库存库位摘要、包装库存摘要和最新质检摘要 | +| `external.product_catalog_wide_v1` | 一条码一行的商品目录聚合宽表,覆盖正式条码与可消费的 non-integrated ready 条码,以及产品资料、开发阶段负责人、限价、库存库位摘要、包装库存摘要和最新质检摘要 | | `external.intake_product_limits_v1` | 非集成产品限价白名单 | | `external.warehouse_locations_v1` | 仓库库位资料 | | `external.sku_warehouse_stock_v1` | 正式 SKU 库存和库位 | @@ -232,12 +232,17 @@ CREATE SCHEMA IF NOT EXISTS external; 只读账号授权原则: ```sql -GRANT USAGE ON SCHEMA external TO external_reader; -GRANT SELECT ON ALL TABLES IN SCHEMA external TO external_reader; -ALTER DEFAULT PRIVILEGES IN SCHEMA external -GRANT SELECT ON TABLES TO external_reader; +GRANT USAGE ON SCHEMA external TO external_core_reader; +GRANT SELECT ON external.product_catalog_wide_v1 TO external_core_reader; +GRANT SELECT ON external.intake_product_limits_v1 TO external_core_reader; +GRANT SELECT ON external.warehouse_locations_v1 TO external_core_reader; +GRANT SELECT ON external.sku_warehouse_stock_v1 TO external_core_reader; +GRANT SELECT ON external.packaged_items_v1 TO external_core_reader; +GRANT SELECT ON external.sku_quality_inspections_v1 TO external_core_reader; ``` +不要给外部账号使用 `SELECT ON ALL TABLES IN SCHEMA external` 这类全量授权;新增外部视图必须单独评审后追加白名单。 + 禁止: - 给外部账号授权 `public` schema 全表读取。 diff --git a/furniture-3d-system/prisma/migrations/20260702143000_normalize_external_readonly_asset_urls/migration.sql b/furniture-3d-system/prisma/migrations/20260702143000_normalize_external_readonly_asset_urls/migration.sql new file mode 100644 index 00000000..8fab7ec0 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260702143000_normalize_external_readonly_asset_urls/migration.sql @@ -0,0 +1,390 @@ +-- Normalize asset URLs exposed by external read-only views. +-- +-- Business tables still store stable in-app object routes such as +-- /api/storage/object?key=... . External database consumers need complete +-- URLs, so the external contract layer prefixes relative application routes +-- with the current production site origin. The routes remain application +-- controlled; this migration does not expose private bucket objects directly. + +CREATE OR REPLACE FUNCTION external.to_external_asset_url(raw_value text) +RETURNS text +LANGUAGE sql +STABLE +AS $$ +WITH normalized AS ( + SELECT NULLIF(btrim(raw_value), '') AS value +), +base_url AS ( + SELECT COALESCE( + NULLIF(btrim(current_setting('app.external_asset_base_url', true)), ''), + NULLIF(btrim(current_setting('app.public_base_url', true)), ''), + 'https://cp.jiansun.vip' + ) AS value +) +SELECT + CASE + WHEN normalized.value IS NULL THEN NULL + WHEN normalized.value ~* '^https?://(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?/' THEN + regexp_replace(base_url.value, '/+$', '') || + regexp_replace( + normalized.value, + '^https?://(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?', + '', + 'i' + ) + WHEN normalized.value LIKE '/%' THEN regexp_replace(base_url.value, '/+$', '') || normalized.value + ELSE normalized.value + END +FROM normalized, base_url; +$$; + +CREATE OR REPLACE VIEW external.materials_v1 AS +SELECT + m."id" AS material_id, + m."code" AS material_code, + m."name" AS material_name, + c."name" AS category_name, + external.to_external_asset_url(m."thumbnailUrl") AS thumbnail_url, + external.to_external_asset_url(m."textureUrl") AS texture_url, + external.to_external_asset_url(m."normalMapUrl") AS normal_map_url, + m."color" AS base_color, + m."metalness" AS metalness, + m."roughness" AS roughness, + m."opacity" AS opacity, + CASE WHEN m."isActive" THEN 'ACTIVE' ELSE 'INACTIVE' END AS record_status +FROM "Material" m +JOIN "MaterialCategory" c ON c."id" = m."categoryId"; + +CREATE OR REPLACE VIEW external.asset_variants_v1 AS +SELECT + v."id" AS asset_variant_id, + v."code" AS spec_code, + v."name" AS spec_name, + v."assetType"::text AS asset_type, + v."status"::text AS asset_status, + f."id" AS asset_family_id, + f."code" AS asset_family_code, + f."name" AS asset_family_name, + s."id" AS asset_series_id, + s."code" AS asset_series_code, + s."name" AS asset_series_name, + pc."id" AS product_category_id, + pc."name" AS product_category_name, + v."sizeLabel" AS size_label, + v."dimensions" AS dimensions, + external.to_external_asset_url(v."modelUrl") AS model_url, + external.to_external_asset_url(v."thumbnailUrl") AS thumbnail_url, + v."barcode" AS barcode, + v."supplierName" AS supplier_name, + v."supplierCode" AS supplier_code, + v."versionNo" AS version_no, + sub."id" AS substrate_id, + sub."name" AS substrate_name, + finish."id" AS finish_id, + finish."code" AS finish_code, + finish."name" AS finish_name, + CASE + WHEN sub."name" IS NOT NULL AND finish."name" IS NOT NULL THEN sub."name" || ' + ' || finish."name" + ELSE COALESCE(sub."name", finish."name") + END AS material_label, + v."weightKg" AS weight_kg, + v."costPrice" AS cost_price, + v."estimatedCostPrice" AS estimated_cost_price, + v."createdAt" AS created_at, + v."updatedAt" AS updated_at +FROM "AssetVariant" v +JOIN "AssetFamily" f ON f."id" = v."assetFamilyId" +LEFT JOIN "AssetSeries" s ON s."id" = f."assetSeriesId" +LEFT JOIN "ProductCategory" pc ON pc."id" = f."productCategoryId" +LEFT JOIN "Substrate" sub ON sub."id" = v."substrateId" +LEFT JOIN "Material" finish ON finish."id" = v."finishId" +WHERE v."deletedAt" IS NULL; + +CREATE OR REPLACE VIEW external.product_skus_v1 AS +SELECT + p."id" AS product_id, + p."modelNumber" AS product_model_number, + p."name" AS product_name, + p."description" AS product_description, + pc."id" AS product_category_id, + pc."name" AS product_category_name, + external.to_external_asset_url(p."thumbnailUrl") AS product_thumbnail_url, + external.to_external_asset_url(p."modelUrl") AS product_model_url, + sku."id" AS sku_id, + sku."skuCode" AS sku_code, + COALESCE( + NULLIF(sku."name", ''), + NULLIF(profile."specName", ''), + sku."skuCode" + ) AS sku_name, + sku."price" AS price, + sku."costPrice" AS cost_price, + sku."stock" AS stock, + sku."isActive" AS is_active, + CASE WHEN sku."isActive" THEN 'ACTIVE' ELSE 'INACTIVE' END AS record_status, + external.to_external_asset_url( + COALESCE(NULLIF(sku."modelUrl", ''), NULLIF(p."modelUrl", '')) + ) AS model_url, + external.to_external_asset_url( + COALESCE( + NULLIF(profile."imageUrl", ''), + sku."images"[1], + NULLIF(p."thumbnailUrl", '') + ) + ) AS image_url, + ARRAY( + SELECT external.to_external_asset_url(image_item.image_url) + FROM unnest(sku."images") AS image_item(image_url) + WHERE NULLIF(btrim(image_item.image_url), '') IS NOT NULL + ) AS image_urls, + sku."sourcePreSkuId" AS source_pre_sku_id, + sku."sourceAssetVariantId" AS source_asset_variant_id, + sku."sourceAssetVariantIds" AS source_asset_variant_ids, + profile."model" AS asset_family_code, + profile."modelName" AS asset_family_name, + profile."specCode" AS spec_code, + profile."specName" AS spec_name, + profile."barcode" AS barcode, + profile."size" AS size_json, + profile."color" AS color, + profile."material" AS material, + profile."sourceStatus"::text AS source_status, + profile."syncedAt"::text AS synced_at, + wh."id" AS warehouse_location_id, + wh."code" AS warehouse_location_code, + wh."name" AS warehouse_location_name, + sku."createdAt" AS created_at, + sku."updatedAt" AS updated_at, + CASE + WHEN NULLIF(btrim(COALESCE(p."modelNumber", '')), '') IS NOT NULL + THEN upper(btrim(p."modelNumber")) + ELSE NULL + END AS product_business_key, + CASE + WHEN NULLIF(btrim(COALESCE(sku."skuCode", '')), '') IS NOT NULL + THEN upper(btrim(sku."skuCode")) + ELSE NULL + END AS sku_business_key, + CASE + WHEN NULLIF(btrim(COALESCE(profile."barcode", '')), '') IS NOT NULL + THEN upper(btrim(profile."barcode")) + ELSE NULL + END AS barcode_business_key +FROM "ProductSKU" sku +JOIN "Product" p ON p."id" = sku."productId" +LEFT JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = sku."id" +LEFT JOIN "ProductCategory" pc ON pc."id" = p."categoryId" +LEFT JOIN "WarehouseLocation" wh ON wh."id" = sku."warehouseLocationId" +WHERE p."deletedAt" IS NULL + AND sku."deletedAt" IS NULL; + +CREATE OR REPLACE VIEW external.product_assets_v1 AS +WITH image_assets AS ( + SELECT + sku."id" AS sku_id, + sku."productId" AS product_id, + p."modelNumber" AS product_model_number, + sku."skuCode" AS sku_code, + profile."barcode" AS barcode, + image_item.image_url, + image_item.sort_order + FROM "ProductSKU" sku + JOIN "Product" p ON p."id" = sku."productId" + LEFT JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = sku."id" + CROSS JOIN LATERAL unnest(sku."images") WITH ORDINALITY AS image_item(image_url, sort_order) + WHERE p."deletedAt" IS NULL + AND sku."deletedAt" IS NULL +), +model_assets AS ( + SELECT + sku."id" AS sku_id, + sku."productId" AS product_id, + p."modelNumber" AS product_model_number, + sku."skuCode" AS sku_code, + profile."barcode" AS barcode, + COALESCE(NULLIF(sku."modelUrl", ''), NULLIF(p."modelUrl", '')) AS model_url + FROM "ProductSKU" sku + JOIN "Product" p ON p."id" = sku."productId" + LEFT JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = sku."id" + WHERE p."deletedAt" IS NULL + AND sku."deletedAt" IS NULL +), +thumbnail_assets AS ( + SELECT + sku."id" AS sku_id, + sku."productId" AS product_id, + p."modelNumber" AS product_model_number, + sku."skuCode" AS sku_code, + profile."barcode" AS barcode, + COALESCE( + NULLIF(profile."imageUrl", ''), + NULLIF(p."thumbnailUrl", '') + ) AS thumbnail_url + FROM "ProductSKU" sku + JOIN "Product" p ON p."id" = sku."productId" + LEFT JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = sku."id" + WHERE p."deletedAt" IS NULL + AND sku."deletedAt" IS NULL +) +SELECT + 'SKU_IMAGE' AS asset_type, + product_id, + sku_id, + external.to_external_asset_url(image_url) AS asset_url, + NULL::text AS file_name, + NULL::text AS mime_type, + sort_order::integer AS sort_order, + CASE + WHEN NULLIF(btrim(COALESCE(product_model_number, '')), '') IS NOT NULL + THEN upper(btrim(product_model_number)) + ELSE NULL + END AS product_business_key, + CASE + WHEN NULLIF(btrim(COALESCE(sku_code, '')), '') IS NOT NULL + THEN upper(btrim(sku_code)) + ELSE NULL + END AS sku_business_key, + CASE + WHEN NULLIF(btrim(COALESCE(barcode, '')), '') IS NOT NULL + THEN upper(btrim(barcode)) + ELSE NULL + END AS barcode_business_key +FROM image_assets +WHERE NULLIF(image_url, '') IS NOT NULL +UNION ALL +SELECT + 'SKU_MODEL' AS asset_type, + product_id, + sku_id, + external.to_external_asset_url(model_url) AS asset_url, + NULL::text AS file_name, + NULL::text AS mime_type, + 0 AS sort_order, + CASE + WHEN NULLIF(btrim(COALESCE(product_model_number, '')), '') IS NOT NULL + THEN upper(btrim(product_model_number)) + ELSE NULL + END AS product_business_key, + CASE + WHEN NULLIF(btrim(COALESCE(sku_code, '')), '') IS NOT NULL + THEN upper(btrim(sku_code)) + ELSE NULL + END AS sku_business_key, + CASE + WHEN NULLIF(btrim(COALESCE(barcode, '')), '') IS NOT NULL + THEN upper(btrim(barcode)) + ELSE NULL + END AS barcode_business_key +FROM model_assets +WHERE NULLIF(model_url, '') IS NOT NULL +UNION ALL +SELECT + 'SKU_THUMBNAIL' AS asset_type, + product_id, + sku_id, + external.to_external_asset_url(thumbnail_url) AS asset_url, + NULL::text AS file_name, + NULL::text AS mime_type, + 0 AS sort_order, + CASE + WHEN NULLIF(btrim(COALESCE(product_model_number, '')), '') IS NOT NULL + THEN upper(btrim(product_model_number)) + ELSE NULL + END AS product_business_key, + CASE + WHEN NULLIF(btrim(COALESCE(sku_code, '')), '') IS NOT NULL + THEN upper(btrim(sku_code)) + ELSE NULL + END AS sku_business_key, + CASE + WHEN NULLIF(btrim(COALESCE(barcode, '')), '') IS NOT NULL + THEN upper(btrim(barcode)) + ELSE NULL + END AS barcode_business_key +FROM thumbnail_assets +WHERE NULLIF(thumbnail_url, '') IS NOT NULL; + +CREATE OR REPLACE VIEW external.intake_products_v1 AS +SELECT + product."id" AS intake_product_id, + product."spu" AS spu, + product."spuBase" AS spu_base, + product."brand" AS brand, + profile."productName" AS product_name, + external.to_external_asset_url(profile."productImageUrl") AS product_image_url, + profile."productCategory" AS product_category, + profile."salesCategory" AS sales_category, + profile."productYear" AS product_year, + profile."productStatus" AS product_status, + COALESCE(NULLIF(profile."structureDesigner", ''), NULLIF(product."designer", '')) AS designer, + COALESCE(NULLIF(profile."productPlanning", ''), NULLIF(product."operator", '')) AS operator, + COALESCE(NULLIF(profile."purchaseOwner", ''), NULLIF(product."purchaser", '')) AS purchaser, + profile."productDevelopmentKind" AS product_development_kind, + profile."goodsType" AS goods_type, + profile."materialType" AS material_type, + profile."productionType" AS production_type, + product."createdAt" AS created_at, + product."updatedAt" AS updated_at, + profile."productPlanning" AS product_planning, + profile."visualPlanning" AS visual_planning, + profile."structureDesigner" AS structure_designer, + profile."purchaseOwner" AS purchase_owner, + CASE + WHEN NULLIF(btrim(COALESCE(product."brand", '')), '') IS NOT NULL + AND NULLIF(btrim(COALESCE(product."spu", '')), '') IS NOT NULL + THEN upper(btrim(product."brand")) || ':' || upper(btrim(product."spu")) + ELSE NULL + END AS product_business_key +FROM "IntakeNonIntegratedProduct" product +LEFT JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id" +WHERE product."deletedAt" IS NULL; + +CREATE OR REPLACE VIEW external.intake_sku_items_v1 AS +SELECT + product."id" AS intake_product_id, + product."spu" AS spu, + product."brand" AS brand, + sku."skuItemId" AS intake_sku_item_id, + sku."skuSequence" AS sku_sequence, + sku."batchNo" AS batch_no, + external.to_external_asset_url(sku."image") AS image_url, + sku."imageName" AS image_name, + sku."model" AS model, + sku."skuCode" AS sku_code, + sku."specName" AS spec_name, + sku."barcode" AS barcode, + sku."size" AS size, + sku."color" AS color, + sku."material" AS material, + sku."versionNo" AS version_no, + sku."attributeCode" AS attribute_code, + sku."drawingId" AS drawing_id, + sku."installMethod" AS install_method, + sku."executeStandard" AS execute_standard, + sku."manufacturer" AS manufacturer, + sku."manufacturerCode" AS manufacturer_code, + sku."salesStatus" AS sales_status, + external.to_external_asset_url(sku."model3dDataUrl") AS model_3d_url, + sku."source" AS source, + sku."sourceTable" AS source_table, + sku."sourceSkuId" AS source_sku_id, + sku."missingRequiredFields" AS missing_required_fields, + sku."dataQualityStatus"::text AS data_quality_status, + sku."contentLocked" AS content_locked, + sku."sortOrder" AS sort_order, + CASE + WHEN NULLIF(btrim(COALESCE(product."brand", '')), '') IS NOT NULL + AND NULLIF(btrim(COALESCE(product."spu", '')), '') IS NOT NULL + THEN upper(btrim(product."brand")) || ':' || upper(btrim(product."spu")) + ELSE NULL + END AS product_business_key, + CASE + WHEN NULLIF(btrim(COALESCE(product."brand", '')), '') IS NOT NULL + AND NULLIF(btrim(COALESCE(product."spu", '')), '') IS NOT NULL + AND NULLIF(btrim(COALESCE(sku."skuCode", '')), '') IS NOT NULL + THEN upper(btrim(product."brand")) || ':' || upper(btrim(product."spu")) || ':' || upper(btrim(sku."skuCode")) + ELSE NULL + END AS sku_business_key +FROM "IntakeNonIntegratedSkuItem" sku +JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId" +WHERE product."deletedAt" IS NULL; diff --git a/furniture-3d-system/prisma/migrations/20260703110000_add_development_people_to_external_catalog_wide/migration.sql b/furniture-3d-system/prisma/migrations/20260703110000_add_development_people_to_external_catalog_wide/migration.sql new file mode 100644 index 00000000..450b0618 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260703110000_add_development_people_to_external_catalog_wide/migration.sql @@ -0,0 +1,510 @@ +-- Add explicit development-owner columns to the external catalog wide view. +-- +-- The external main entry remains external.product_catalog_wide_v1. The new +-- columns mirror the product-development list people fields and are appended to +-- the existing wide view so current consumers keep their column order. + +CREATE OR REPLACE VIEW external.intake_products_v1 AS +SELECT + product."id" AS intake_product_id, + product."spu" AS spu, + product."spuBase" AS spu_base, + product."brand" AS brand, + profile."productName" AS product_name, + external.to_external_asset_url(profile."productImageUrl") AS product_image_url, + profile."productCategory" AS product_category, + profile."salesCategory" AS sales_category, + profile."productYear" AS product_year, + profile."productStatus" AS product_status, + COALESCE(NULLIF(profile."structureDesigner", ''), NULLIF(product."designer", '')) AS designer, + COALESCE(NULLIF(profile."productPlanning", ''), NULLIF(product."operator", '')) AS operator, + COALESCE(NULLIF(profile."purchaseOwner", ''), NULLIF(product."purchaser", '')) AS purchaser, + profile."productDevelopmentKind" AS product_development_kind, + profile."goodsType" AS goods_type, + profile."materialType" AS material_type, + profile."productionType" AS production_type, + product."createdAt" AS created_at, + product."updatedAt" AS updated_at, + profile."productPlanning" AS product_planning, + profile."visualPlanning" AS visual_planning, + profile."structureDesigner" AS structure_designer, + profile."purchaseOwner" AS purchase_owner, + CASE + WHEN NULLIF(btrim(COALESCE(product."brand", '')), '') IS NOT NULL + AND NULLIF(btrim(COALESCE(product."spu", '')), '') IS NOT NULL + THEN upper(btrim(product."brand")) || ':' || upper(btrim(product."spu")) + ELSE NULL + END AS product_business_key, + NULLIF(btrim(COALESCE(product."designer", '')), '') AS appearance_designer, + COALESCE( + NULLIF(btrim(COALESCE(profile."spuOwner", '')), ''), + NULLIF(btrim(COALESCE(product."operator", '')), ''), + NULLIF(btrim(COALESCE(profile."operationsOwner", '')), '') + ) AS spu_owner, + NULLIF(btrim(COALESCE(profile."productPlanning", '')), '') AS planning_owner, + COALESCE( + NULLIF(btrim(COALESCE(profile."visualPlanning", '')), ''), + NULLIF(btrim(COALESCE(profile."visualDirector", '')), '') + ) AS space_designer +FROM "IntakeNonIntegratedProduct" product +LEFT JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id" +WHERE product."deletedAt" IS NULL; + +CREATE OR REPLACE VIEW external.intake_products_ready_v1 AS +SELECT * +FROM external.intake_products_v1 +WHERE NULLIF(btrim(COALESCE(product_name, '')), '') IS NOT NULL + AND NULLIF(btrim(COALESCE(product_category, '')), '') IS NOT NULL + AND NULLIF(btrim(COALESCE(product_development_kind, '')), '') IS NOT NULL + AND NULLIF(btrim(COALESCE(product_business_key, '')), '') IS NOT NULL; + +CREATE OR REPLACE VIEW external.product_catalog_wide_v1 AS +WITH formal_sku_source AS ( + SELECT + sku.product_id, + sku.product_model_number, + sku.product_name, + sku.product_description, + sku.product_category_id, + sku.product_category_name, + sku.product_thumbnail_url, + sku.product_model_url, + sku.sku_id, + sku.sku_code, + sku.sku_name, + sku.price, + sku.cost_price, + sku.stock, + sku.is_active, + sku.record_status, + sku.model_url, + sku.image_url, + sku.image_urls, + sku.source_pre_sku_id, + sku.source_asset_variant_id, + sku.source_asset_variant_ids, + sku.asset_family_code, + sku.asset_family_name, + sku.spec_code, + sku.spec_name, + sku.barcode, + sku.size_json, + sku.color, + sku.material, + sku.source_status, + sku.synced_at, + sku.warehouse_location_id, + sku.warehouse_location_code, + sku.warehouse_location_name, + sku.created_at, + sku.updated_at, + sku.product_business_key, + sku.sku_business_key, + sku.barcode_business_key, + 'FORMAL'::text AS catalog_source, + NULL::text AS appearance_designer, + NULL::text AS structure_designer, + NULL::text AS spu_owner, + NULL::text AS planning_owner, + NULL::text AS space_designer, + NULL::text AS purchase_owner + FROM external.product_skus_v1 sku +), +formal_identity_map AS ( + SELECT + barcode_business_key, + CASE + WHEN NULLIF(btrim(COALESCE(sku_code, '')), '') IS NOT NULL + THEN upper(btrim(sku_code)) + ELSE NULL + END AS normalized_sku_code + FROM formal_sku_source +), +intake_ready_sku_source AS ( + SELECT + product.intake_product_id AS product_id, + NULLIF(btrim(COALESCE(product.spu, '')), '') AS product_model_number, + NULLIF(btrim(COALESCE(product.product_name, '')), '') AS product_name, + NULL::text AS product_description, + NULL::text AS product_category_id, + NULLIF(btrim(COALESCE(product.product_category, '')), '') AS product_category_name, + COALESCE( + NULLIF(btrim(COALESCE(product.product_image_url, '')), ''), + NULLIF(btrim(COALESCE(sku.image_url, '')), '') + ) AS product_thumbnail_url, + NULL::text AS product_model_url, + sku.intake_sku_item_id AS sku_id, + NULLIF(btrim(COALESCE(sku.sku_code, '')), '') AS sku_code, + COALESCE( + NULLIF(btrim(COALESCE(sku.spec_name, '')), ''), + NULLIF(btrim(COALESCE(sku.sku_code, '')), '') + ) AS sku_name, + NULL::numeric(10,2) AS price, + NULL::numeric(10,2) AS cost_price, + 0::integer AS stock, + true AS is_active, + 'ACTIVE'::text AS record_status, + NULLIF(btrim(COALESCE(sku.model_3d_url, '')), '') AS model_url, + COALESCE( + NULLIF(btrim(COALESCE(sku.image_url, '')), ''), + NULLIF(btrim(COALESCE(product.product_image_url, '')), '') + ) AS image_url, + CASE + WHEN COALESCE( + NULLIF(btrim(COALESCE(sku.image_url, '')), ''), + NULLIF(btrim(COALESCE(product.product_image_url, '')), '') + ) IS NOT NULL + THEN ARRAY[ + COALESCE( + NULLIF(btrim(COALESCE(sku.image_url, '')), ''), + NULLIF(btrim(COALESCE(product.product_image_url, '')), '') + ) + ] + ELSE ARRAY[]::text[] + END AS image_urls, + NULL::text AS source_pre_sku_id, + NULL::text AS source_asset_variant_id, + ARRAY[]::text[] AS source_asset_variant_ids, + NULLIF(btrim(COALESCE(sku.model, '')), '') AS asset_family_code, + NULL::text AS asset_family_name, + NULLIF(btrim(COALESCE(sku.attribute_code, '')), '') AS spec_code, + NULLIF(btrim(COALESCE(sku.spec_name, '')), '') AS spec_name, + NULLIF(btrim(COALESCE(sku.barcode, '')), '') AS barcode, + CASE + WHEN NULLIF(btrim(COALESCE(sku.size, '')), '') IS NOT NULL + THEN jsonb_build_object('label', btrim(sku.size)) + ELSE NULL::jsonb + END AS size_json, + NULLIF(btrim(COALESCE(sku.color, '')), '') AS color, + NULLIF(btrim(COALESCE(sku.material, '')), '') AS material, + NULL::text AS source_status, + NULL::text AS synced_at, + NULL::text AS warehouse_location_id, + NULL::text AS warehouse_location_code, + NULL::text AS warehouse_location_name, + product.created_at AS created_at, + product.updated_at AS updated_at, + CASE + WHEN NULLIF(btrim(COALESCE(product.spu, '')), '') IS NOT NULL + THEN upper(btrim(product.spu)) + ELSE NULL + END AS product_business_key, + CASE + WHEN NULLIF(btrim(COALESCE(sku.sku_code, '')), '') IS NOT NULL + THEN upper(btrim(sku.sku_code)) + ELSE NULL + END AS sku_business_key, + CASE + WHEN NULLIF(btrim(COALESCE(sku.barcode, '')), '') IS NOT NULL + THEN upper(btrim(sku.barcode)) + ELSE NULL + END AS barcode_business_key, + 'INTAKE_READY'::text AS catalog_source, + product.appearance_designer, + product.structure_designer, + product.spu_owner, + product.planning_owner, + product.space_designer, + product.purchase_owner + FROM external.intake_sku_items_ready_v1 sku + JOIN external.intake_products_ready_v1 product + ON product.intake_product_id = sku.intake_product_id + WHERE NOT EXISTS ( + SELECT 1 + FROM formal_identity_map formal + WHERE ( + NULLIF(btrim(COALESCE(sku.barcode, '')), '') IS NOT NULL + AND formal.barcode_business_key = upper(btrim(sku.barcode)) + ) + OR ( + NULLIF(btrim(COALESCE(sku.barcode, '')), '') IS NULL + AND NULLIF(btrim(COALESCE(sku.sku_code, '')), '') IS NOT NULL + AND formal.normalized_sku_code = upper(btrim(sku.sku_code)) + ) + ) +), +sku_source AS ( + SELECT * FROM formal_sku_source + UNION ALL + SELECT * FROM intake_ready_sku_source +), +asset_summary AS ( + SELECT + product_business_key, + sku_business_key, + COUNT(*) FILTER (WHERE asset_type = 'SKU_IMAGE')::integer AS image_asset_count, + COUNT(*) FILTER (WHERE asset_type = 'SKU_MODEL')::integer AS model_asset_count, + COUNT(*) FILTER (WHERE asset_type = 'SKU_THUMBNAIL')::integer AS thumbnail_asset_count, + MIN(asset_url) FILTER (WHERE asset_type = 'SKU_MODEL') AS first_model_asset_url, + MIN(asset_url) FILTER (WHERE asset_type = 'SKU_THUMBNAIL') AS first_thumbnail_asset_url + FROM external.product_assets_v1 + GROUP BY product_business_key, sku_business_key +), +packaged_summary AS ( + SELECT + barcode_business_key, + COUNT(*)::integer AS packaged_item_count, + COALESCE(SUM(stock_quantity), 0)::integer AS packaged_stock_quantity + FROM external.packaged_items_v1 + WHERE NULLIF(barcode_business_key, '') IS NOT NULL + GROUP BY barcode_business_key +), +quality_summary AS ( + SELECT + barcode_business_key, + COUNT(*)::integer AS quality_record_count, + COUNT(*) FILTER (WHERE source_type = 'WAREHOUSE')::integer AS warehouse_quality_record_count, + COUNT(*) FILTER (WHERE source_type = 'FIRST_BATCH')::integer AS first_batch_quality_record_count + FROM external.sku_quality_inspections_v1 + WHERE NULLIF(barcode_business_key, '') IS NOT NULL + GROUP BY barcode_business_key +), +latest_quality AS ( + SELECT DISTINCT ON (barcode_business_key) + barcode_business_key, + record_key AS latest_quality_record_key, + source_type AS latest_quality_source_type, + batch_no AS latest_quality_batch_no, + inspected_at AS latest_quality_inspected_at, + conclusion AS latest_quality_conclusion, + status AS latest_quality_status + FROM external.sku_quality_inspections_v1 + WHERE NULLIF(barcode_business_key, '') IS NOT NULL + ORDER BY barcode_business_key, COALESCE(inspected_at, updated_at, created_at) DESC, created_at DESC +), +limit_by_barcode AS ( + SELECT DISTINCT ON (identity_value) + identity_value, + product_business_key AS limit_source_product_business_key, + sku_business_key AS limit_source_sku_business_key, + limit_price_applicable, + limit_price_confirmed, + limit_price_status, + expected_daily_limit_price, + expected_limit_gross_margin_rate, + non_spu_owner_limit_price, + limit_confirmed_at + FROM ( + SELECT + upper(btrim(barcode)) AS identity_value, + product_business_key, + sku_business_key, + limit_price_applicable, + limit_price_confirmed, + limit_price_status, + expected_daily_limit_price, + expected_limit_gross_margin_rate, + non_spu_owner_limit_price, + limit_confirmed_at, + updated_at + FROM external.intake_product_limits_v1 + WHERE NULLIF(btrim(COALESCE(barcode, '')), '') IS NOT NULL + ) limit_rows + WHERE NULLIF(identity_value, '') IS NOT NULL + ORDER BY + identity_value, + limit_price_applicable DESC, + limit_price_confirmed DESC, + limit_confirmed_at DESC NULLS LAST, + updated_at DESC +), +limit_by_sku AS ( + SELECT DISTINCT ON (identity_value) + identity_value, + product_business_key AS limit_source_product_business_key, + sku_business_key AS limit_source_sku_business_key, + limit_price_applicable, + limit_price_confirmed, + limit_price_status, + expected_daily_limit_price, + expected_limit_gross_margin_rate, + non_spu_owner_limit_price, + limit_confirmed_at + FROM ( + SELECT + upper(btrim(sku_code)) AS identity_value, + product_business_key, + sku_business_key, + limit_price_applicable, + limit_price_confirmed, + limit_price_status, + expected_daily_limit_price, + expected_limit_gross_margin_rate, + non_spu_owner_limit_price, + limit_confirmed_at, + updated_at + FROM external.intake_product_limits_v1 + WHERE NULLIF(btrim(COALESCE(sku_code, '')), '') IS NOT NULL + ) limit_rows + WHERE NULLIF(identity_value, '') IS NOT NULL + ORDER BY + identity_value, + limit_price_applicable DESC, + limit_price_confirmed DESC, + limit_confirmed_at DESC NULLS LAST, + updated_at DESC +), +catalog_by_sku AS ( +SELECT + sku.product_id, + sku.product_model_number, + sku.product_business_key, + sku.product_name, + sku.product_description, + sku.product_category_id, + sku.product_category_name, + sku.product_thumbnail_url, + sku.product_model_url, + sku.sku_id, + sku.sku_code, + sku.sku_business_key, + sku.sku_name, + sku.price, + sku.cost_price, + sku.stock, + sku.is_active, + sku.record_status, + sku.model_url, + sku.image_url, + sku.image_urls, + sku.source_pre_sku_id, + sku.source_asset_variant_id, + sku.source_asset_variant_ids, + sku.asset_family_code, + sku.asset_family_name, + sku.spec_code, + sku.spec_name, + sku.barcode, + sku.barcode_business_key, + sku.size_json, + sku.color, + sku.material, + sku.source_status, + sku.synced_at, + sku.warehouse_location_id, + sku.warehouse_location_code, + sku.warehouse_location_name, + stock.warehouse_zone, + stock.warehouse_aisle, + stock.warehouse_shelf, + stock.warehouse_bin, + stock.warehouse_location_is_active, + stock.warehouse_location_status, + COALESCE(limit_by_barcode.limit_price_applicable, limit_by_sku.limit_price_applicable, false) AS limit_price_applicable, + COALESCE(limit_by_barcode.limit_price_confirmed, limit_by_sku.limit_price_confirmed, false) AS limit_price_confirmed, + COALESCE(limit_by_barcode.limit_price_status, limit_by_sku.limit_price_status, 'NOT_APPLICABLE') AS limit_price_status, + COALESCE(limit_by_barcode.expected_daily_limit_price, limit_by_sku.expected_daily_limit_price) AS expected_daily_limit_price, + COALESCE(limit_by_barcode.expected_limit_gross_margin_rate, limit_by_sku.expected_limit_gross_margin_rate) AS expected_limit_gross_margin_rate, + COALESCE(limit_by_barcode.non_spu_owner_limit_price, limit_by_sku.non_spu_owner_limit_price) AS non_spu_owner_limit_price, + COALESCE(limit_by_barcode.limit_confirmed_at, limit_by_sku.limit_confirmed_at) AS limit_confirmed_at, + COALESCE(limit_by_barcode.limit_source_product_business_key, limit_by_sku.limit_source_product_business_key) AS limit_source_product_business_key, + COALESCE(limit_by_barcode.limit_source_sku_business_key, limit_by_sku.limit_source_sku_business_key) AS limit_source_sku_business_key, + CASE + WHEN sku.catalog_source = 'INTAKE_READY' + THEN CASE WHEN NULLIF(btrim(COALESCE(sku.image_url, '')), '') IS NOT NULL THEN 1 ELSE 0 END + ELSE COALESCE(asset_summary.image_asset_count, 0) + END AS image_asset_count, + CASE + WHEN sku.catalog_source = 'INTAKE_READY' + THEN CASE WHEN NULLIF(btrim(COALESCE(sku.model_url, '')), '') IS NOT NULL THEN 1 ELSE 0 END + ELSE COALESCE(asset_summary.model_asset_count, 0) + END AS model_asset_count, + CASE + WHEN sku.catalog_source = 'INTAKE_READY' + THEN CASE + WHEN COALESCE( + NULLIF(btrim(COALESCE(sku.product_thumbnail_url, '')), ''), + NULLIF(btrim(COALESCE(sku.image_url, '')), '') + ) IS NOT NULL + THEN 1 + ELSE 0 + END + ELSE COALESCE(asset_summary.thumbnail_asset_count, 0) + END AS thumbnail_asset_count, + COALESCE( + asset_summary.first_model_asset_url, + CASE + WHEN sku.catalog_source = 'INTAKE_READY' + THEN NULLIF(btrim(COALESCE(sku.model_url, '')), '') + ELSE NULL + END + ) AS first_model_asset_url, + COALESCE( + asset_summary.first_thumbnail_asset_url, + CASE + WHEN sku.catalog_source = 'INTAKE_READY' + THEN COALESCE( + NULLIF(btrim(COALESCE(sku.product_thumbnail_url, '')), ''), + NULLIF(btrim(COALESCE(sku.image_url, '')), '') + ) + ELSE NULL + END + ) AS first_thumbnail_asset_url, + COALESCE(bom_summary.bom_item_count, 0) AS bom_item_count, + COALESCE(bom_summary.panel_item_count, 0) AS panel_item_count, + COALESCE(bom_summary.hardware_item_count, 0) AS hardware_item_count, + COALESCE(bom_summary.material_codes, ARRAY[]::text[]) AS material_codes, + COALESCE(bom_summary.material_names, ARRAY[]::text[]) AS material_names, + COALESCE(packaged_summary.packaged_item_count, 0) AS packaged_item_count, + COALESCE(packaged_summary.packaged_stock_quantity, 0) AS packaged_stock_quantity, + COALESCE(quality_summary.quality_record_count, 0) AS quality_record_count, + COALESCE(quality_summary.warehouse_quality_record_count, 0) AS warehouse_quality_record_count, + COALESCE(quality_summary.first_batch_quality_record_count, 0) AS first_batch_quality_record_count, + latest_quality.latest_quality_record_key, + latest_quality.latest_quality_source_type, + latest_quality.latest_quality_batch_no, + latest_quality.latest_quality_inspected_at, + latest_quality.latest_quality_conclusion, + latest_quality.latest_quality_status, + sku.created_at, + sku.updated_at, + sku.catalog_source, + sku.appearance_designer, + sku.structure_designer, + sku.spu_owner, + sku.planning_owner, + sku.space_designer, + sku.purchase_owner +FROM sku_source sku +LEFT JOIN external.sku_warehouse_stock_v1 stock + ON stock.barcode_business_key = sku.barcode_business_key +LEFT JOIN limit_by_sku + ON limit_by_sku.identity_value = upper(btrim(COALESCE(sku.sku_code, ''))) +LEFT JOIN limit_by_barcode + ON limit_by_barcode.identity_value = sku.barcode_business_key +LEFT JOIN asset_summary + ON asset_summary.product_business_key = sku.product_business_key + AND asset_summary.sku_business_key = sku.sku_business_key +LEFT JOIN LATERAL ( + SELECT + COUNT(*)::integer AS bom_item_count, + COUNT(*) FILTER (WHERE item_type = 'PANEL')::integer AS panel_item_count, + COUNT(*) FILTER (WHERE item_type = 'HARDWARE')::integer AS hardware_item_count, + array_remove(array_agg(DISTINCT material_code ORDER BY material_code), NULL) AS material_codes, + array_remove(array_agg(DISTINCT material_name ORDER BY material_name), NULL) AS material_names + FROM external.product_bom_v1 bom + WHERE bom.product_business_key = sku.product_business_key + AND ( + bom.sku_business_key = sku.sku_business_key + OR bom.sku_business_key IS NULL + ) +) bom_summary ON true +LEFT JOIN packaged_summary + ON packaged_summary.barcode_business_key = sku.barcode_business_key +LEFT JOIN quality_summary + ON quality_summary.barcode_business_key = sku.barcode_business_key +LEFT JOIN latest_quality + ON latest_quality.barcode_business_key = sku.barcode_business_key +) +SELECT DISTINCT ON (catalog_by_sku.barcode_business_key) + catalog_by_sku.* +FROM catalog_by_sku +WHERE catalog_by_sku.barcode_business_key IS NOT NULL + AND btrim(catalog_by_sku.barcode_business_key) <> '' +ORDER BY + catalog_by_sku.barcode_business_key, + CASE WHEN catalog_by_sku.catalog_source IS NULL THEN 1 + WHEN catalog_by_sku.catalog_source = 'FORMAL' THEN 0 + ELSE 1 END, + catalog_by_sku.updated_at DESC NULLS LAST, + catalog_by_sku.created_at DESC; diff --git a/furniture-3d-system/scripts/sql/check-external-readonly-contract.sql b/furniture-3d-system/scripts/sql/check-external-readonly-contract.sql index 439ee76c..ab83d7a5 100644 --- a/furniture-3d-system/scripts/sql/check-external-readonly-contract.sql +++ b/furniture-3d-system/scripts/sql/check-external-readonly-contract.sql @@ -2,6 +2,11 @@ -- Run after applying the external schema migration. -- This script is read-only and only reports contract or data risks. +\if :{?reader_role} +\else + \set reader_role external_core_reader +\endif + WITH expected_views(view_name) AS ( VALUES ('materials_v1'), @@ -192,6 +197,12 @@ WITH expected_columns(view_name, column_name) AS ( ('product_catalog_wide_v1', 'latest_quality_status'), ('product_catalog_wide_v1', 'created_at'), ('product_catalog_wide_v1', 'updated_at'), + ('product_catalog_wide_v1', 'appearance_designer'), + ('product_catalog_wide_v1', 'structure_designer'), + ('product_catalog_wide_v1', 'spu_owner'), + ('product_catalog_wide_v1', 'planning_owner'), + ('product_catalog_wide_v1', 'space_designer'), + ('product_catalog_wide_v1', 'purchase_owner'), ('warehouse_locations_v1', 'warehouse_location_id'), ('warehouse_locations_v1', 'warehouse_location_code'), ('warehouse_locations_v1', 'warehouse_location_name'), @@ -367,6 +378,10 @@ WITH expected_columns(view_name, column_name) AS ( ('intake_products_v1', 'production_type'), ('intake_products_v1', 'created_at'), ('intake_products_v1', 'updated_at'), + ('intake_products_v1', 'appearance_designer'), + ('intake_products_v1', 'spu_owner'), + ('intake_products_v1', 'planning_owner'), + ('intake_products_v1', 'space_designer'), ('intake_products_ready_v1', 'intake_product_id'), ('intake_products_ready_v1', 'spu'), ('intake_products_ready_v1', 'spu_base'), @@ -391,6 +406,10 @@ WITH expected_columns(view_name, column_name) AS ( ('intake_products_ready_v1', 'production_type'), ('intake_products_ready_v1', 'created_at'), ('intake_products_ready_v1', 'updated_at'), + ('intake_products_ready_v1', 'appearance_designer'), + ('intake_products_ready_v1', 'spu_owner'), + ('intake_products_ready_v1', 'planning_owner'), + ('intake_products_ready_v1', 'space_designer'), ('intake_sku_items_v1', 'intake_product_id'), ('intake_sku_items_v1', 'spu'), ('intake_sku_items_v1', 'brand'), @@ -499,24 +518,24 @@ WHERE schemaname = 'external' ORDER BY viewname, dependency_hint; SELECT - 'external_reader_public_schema_grant_risk' AS check_name, + 'external_readonly_role_public_schema_grant_risk' AS check_name, grantee, table_schema, table_name, privilege_type FROM information_schema.table_privileges -WHERE grantee = 'external_reader' +WHERE grantee = :'reader_role' AND table_schema = 'public' ORDER BY table_name, privilege_type; SELECT - 'external_reader_non_select_grant_risk' AS check_name, + 'external_readonly_role_non_select_grant_risk' AS check_name, grantee, table_schema, table_name, privilege_type FROM information_schema.table_privileges -WHERE grantee = 'external_reader' +WHERE grantee = :'reader_role' AND table_schema = 'external' AND privilege_type <> 'SELECT' ORDER BY table_name, privilege_type;