feat(external): expose development owners in catalog wide view

This commit is contained in:
WuYongJie 2026-07-03 13:26:59 +08:00
parent 1cbfeba4a6
commit 611b7842c0
7 changed files with 958 additions and 17 deletions

View File

@ -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 <DB_NAME> SET app.external_asset_base_url = '<APP_ORIGIN>';`;未设置时默认使用正式站点 `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 原表。

View File

@ -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`

View File

@ -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`。

View File

@ -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 全表读取。

View File

@ -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;

View File

@ -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;

View File

@ -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;