Katalog, lernendes Mapping ohne KI, optionale Items und Import-Policy. Playwright-Smoke und Issue-Audit um Ernährung/Zuordnen/API ergänzt. Co-authored-by: Cursor <cursoragent@cursor.com>
206 lines
9.3 KiB
SQL
206 lines
9.3 KiB
SQL
-- Migration 062: BLS/food catalog, typed attributes, FDDB mapping, nutrition items, day marks
|
|
-- Additive only. Official BLS rows upsert by bls_code / attr_key — never delete+reinsert.
|
|
|
|
CREATE TABLE IF NOT EXISTS food_attributes (
|
|
id SERIAL PRIMARY KEY,
|
|
attr_key VARCHAR(64) NOT NULL,
|
|
name_de VARCHAR(255) NOT NULL,
|
|
name_en VARCHAR(255),
|
|
unit VARCHAR(40),
|
|
category VARCHAR(80),
|
|
data_type VARCHAR(20) NOT NULL DEFAULT 'num_per_100g'
|
|
CHECK (data_type IN ('num_per_100g', 'boolean', 'text', 'enum')),
|
|
enum_values JSONB,
|
|
origin VARCHAR(20) NOT NULL DEFAULT 'official_bls'
|
|
CHECK (origin IN ('official_bls', 'extension')),
|
|
sort_order INT NOT NULL DEFAULT 0,
|
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
CONSTRAINT uq_food_attributes_key UNIQUE (attr_key)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_food_attributes_origin ON food_attributes (origin);
|
|
|
|
CREATE TABLE IF NOT EXISTS food_catalog (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
bls_code VARCHAR(16),
|
|
external_key VARCHAR(80),
|
|
name_de VARCHAR(500) NOT NULL,
|
|
name_en VARCHAR(500),
|
|
food_group VARCHAR(8),
|
|
catalog_kind VARCHAR(20) NOT NULL DEFAULT 'official_bls'
|
|
CHECK (catalog_kind IN ('official_bls', 'manual_admin', 'manual_user')),
|
|
owner_profile_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
|
|
bls_version VARCHAR(16),
|
|
source VARCHAR(40) NOT NULL DEFAULT 'bls_4.0',
|
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
CONSTRAINT uq_food_catalog_bls_code UNIQUE (bls_code),
|
|
CONSTRAINT chk_food_catalog_bls_code CHECK (
|
|
(catalog_kind = 'official_bls' AND bls_code IS NOT NULL)
|
|
OR (catalog_kind <> 'official_bls' AND bls_code IS NULL)
|
|
)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_food_catalog_name_de ON food_catalog (lower(name_de));
|
|
CREATE INDEX IF NOT EXISTS idx_food_catalog_kind ON food_catalog (catalog_kind);
|
|
CREATE INDEX IF NOT EXISTS idx_food_catalog_owner ON food_catalog (owner_profile_id)
|
|
WHERE owner_profile_id IS NOT NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS food_attribute_values (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
food_id UUID NOT NULL REFERENCES food_catalog(id) ON DELETE CASCADE,
|
|
attribute_id INT NOT NULL REFERENCES food_attributes(id) ON DELETE CASCADE,
|
|
value_num DOUBLE PRECISION,
|
|
value_bool BOOLEAN,
|
|
value_text TEXT,
|
|
is_trace BOOLEAN NOT NULL DEFAULT false,
|
|
origin_code VARCHAR(80),
|
|
reference_text TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
CONSTRAINT uq_food_attribute_value UNIQUE (food_id, attribute_id),
|
|
CONSTRAINT chk_food_attr_one_value CHECK (
|
|
(
|
|
(value_num IS NOT NULL)::int
|
|
+ (value_bool IS NOT NULL)::int
|
|
+ (value_text IS NOT NULL)::int
|
|
) <= 1
|
|
)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_fav_food ON food_attribute_values (food_id);
|
|
CREATE INDEX IF NOT EXISTS idx_fav_attr ON food_attribute_values (attribute_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS food_name_mappings (
|
|
id SERIAL PRIMARY KEY,
|
|
source_system VARCHAR(20) NOT NULL DEFAULT 'fddb',
|
|
source_name_raw VARCHAR(500) NOT NULL,
|
|
source_name_normalized VARCHAR(500) NOT NULL,
|
|
food_id UUID NOT NULL REFERENCES food_catalog(id) ON DELETE CASCADE,
|
|
profile_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
|
|
source VARCHAR(20) NOT NULL DEFAULT 'manual'
|
|
CHECK (source IN ('manual', 'bulk', 'admin')),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_food_map_global
|
|
ON food_name_mappings (source_system, source_name_normalized)
|
|
WHERE profile_id IS NULL;
|
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_food_map_user
|
|
ON food_name_mappings (source_system, source_name_normalized, profile_id)
|
|
WHERE profile_id IS NOT NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_food_map_food ON food_name_mappings (food_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS nutrition_items (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
date DATE NOT NULL,
|
|
logged_at TIMESTAMPTZ,
|
|
source_name_raw VARCHAR(500) NOT NULL,
|
|
source_name_normalized VARCHAR(500) NOT NULL,
|
|
source_system VARCHAR(20) NOT NULL DEFAULT 'fddb',
|
|
quantity_raw VARCHAR(80),
|
|
quantity_g NUMERIC(10,3),
|
|
fddb_kcal NUMERIC(8,2),
|
|
fddb_protein_g NUMERIC(8,2),
|
|
fddb_fat_g NUMERIC(8,2),
|
|
fddb_carbs_g NUMERIC(8,2),
|
|
food_id UUID REFERENCES food_catalog(id) ON DELETE SET NULL,
|
|
mapping_id INT REFERENCES food_name_mappings(id) ON DELETE SET NULL,
|
|
value_origin VARCHAR(20) NOT NULL DEFAULT 'fddb'
|
|
CHECK (value_origin IN ('fddb', 'bls', 'manual_catalog')),
|
|
source VARCHAR(20) NOT NULL DEFAULT 'csv'
|
|
CHECK (source IN ('csv', 'manual')),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_nutrition_items_profile_date
|
|
ON nutrition_items (profile_id, date DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_nutrition_items_unmapped
|
|
ON nutrition_items (profile_id, source_name_normalized)
|
|
WHERE food_id IS NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS nutrition_daily_nutrients (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
date DATE NOT NULL,
|
|
attribute_id INT NOT NULL REFERENCES food_attributes(id) ON DELETE CASCADE,
|
|
value DOUBLE PRECISION NOT NULL,
|
|
contributing_item_count INT NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
CONSTRAINT uq_nutrition_daily_nutrient UNIQUE (profile_id, date, attribute_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_ndn_profile_date
|
|
ON nutrition_daily_nutrients (profile_id, date DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS nutrition_day_marks (
|
|
id SERIAL PRIMARY KEY,
|
|
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
date DATE NOT NULL,
|
|
mark_type VARCHAR(20) NOT NULL CHECK (mark_type IN ('fasting', 'incomplete')),
|
|
note TEXT,
|
|
source VARCHAR(20) NOT NULL DEFAULT 'manual',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
CONSTRAINT uq_nutrition_day_mark UNIQUE (profile_id, date)
|
|
);
|
|
|
|
ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS macro_origin VARCHAR(20);
|
|
ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS mapped_item_count INT;
|
|
ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS unmapped_item_count INT;
|
|
ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS has_items BOOLEAN DEFAULT false;
|
|
ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS last_import_at TIMESTAMPTZ;
|
|
ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS macros_confirmed_at TIMESTAMPTZ;
|
|
|
|
DELETE FROM nutrition_log a
|
|
USING nutrition_log b
|
|
WHERE a.profile_id = b.profile_id
|
|
AND a.date = b.date
|
|
AND (a.created < b.created OR (a.created = b.created AND a.id::text < b.id::text));
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_nutrition_log_profile_date
|
|
ON nutrition_log (profile_id, date);
|
|
|
|
ALTER TABLE profiles
|
|
ADD COLUMN IF NOT EXISTS nutrition_import_conflict_policy VARCHAR(30) DEFAULT 'prompt';
|
|
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM pg_constraint WHERE conname = 'chk_nutrition_import_policy'
|
|
) THEN
|
|
ALTER TABLE profiles ADD CONSTRAINT chk_nutrition_import_policy
|
|
CHECK (nutrition_import_conflict_policy IN (
|
|
'prompt', 'overwrite_catalog', 'overwrite_fddb', 'keep_existing'
|
|
));
|
|
END IF;
|
|
END $$;
|
|
|
|
UPDATE csv_field_mappings
|
|
SET field_mappings = COALESCE(field_mappings, '{}'::jsonb)
|
|
|| '{"bezeichnung": "food_name", "menge": "quantity_raw"}'::jsonb,
|
|
updated_at = NOW()
|
|
WHERE is_system = true
|
|
AND module = 'nutrition'
|
|
AND (
|
|
mapping_name ILIKE '%FDDB%'
|
|
OR mapping_name ILIKE '%fddb%'
|
|
);
|
|
|
|
COMMENT ON TABLE food_catalog IS 'BLS 4.0 official foods + manual catalog extensions';
|
|
COMMENT ON COLUMN food_catalog.bls_code IS 'Stable official BLS identity; upsert key';
|
|
COMMENT ON TABLE food_name_mappings IS 'Learned FDDB name -> food_catalog; user overrides global';
|
|
COMMENT ON TABLE nutrition_day_marks IS 'Explicit fasting/incomplete; import must not delete';
|
|
|
|
DO $$
|
|
BEGIN
|
|
RAISE NOTICE 'Migration 062: BLS food catalog, mappings, nutrition items, day marks';
|
|
END $$;
|