-- Item Master Schema v1
-- 核心流程:invoice_name_cluster -> sellable_item
-- 設計重點:
-- 1. barcode 以證據與屬性的角色建模,不當主鍵。
-- 2. `sellable_item` 是面向發票與審核的主要目標;`base_item` 負責往上彙總。
-- 3. `unit`、`bundle`、`carton` 都是第一級的可售層級。
-- 4. 對外代碼採用 BI-<家族碼> 與 SI-<家族碼>-<U/B/C><序號>。
BEGIN;
CREATE SCHEMA IF NOT EXISTS invoice_item_master_v1;
SET search_path TO invoice_item_master_v1, public;
CREATE TABLE base_items (
base_item_id BIGSERIAL PRIMARY KEY,
base_item_code TEXT NOT NULL UNIQUE
CHECK (base_item_code ~ '^BI-[0-9A-HJKMNPQRSTVWXYZ]{6}$'),
canonical_name TEXT NOT NULL,
brand TEXT,
category TEXT,
spec TEXT,
item_tier TEXT NOT NULL DEFAULT 'bronze'
CHECK (item_tier IN ('bronze', 'silver', 'gold')),
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'active', 'deprecated')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE sellable_items (
sellable_item_id BIGSERIAL PRIMARY KEY,
base_item_id BIGINT NOT NULL REFERENCES base_items(base_item_id),
sellable_item_code TEXT NOT NULL UNIQUE
CHECK (sellable_item_code ~ '^SI-[0-9A-HJKMNPQRSTVWXYZ]{6}-[UBC][0-9]{3}$'),
sellable_name TEXT NOT NULL,
item_level TEXT NOT NULL
CHECK (item_level IN ('unit', 'bundle', 'carton')),
package_form TEXT,
pack_qty NUMERIC(12, 3),
pack_uom TEXT,
net_content_value NUMERIC(12, 3),
net_content_uom TEXT,
effective_from DATE,
effective_to DATE,
item_tier TEXT NOT NULL DEFAULT 'bronze'
CHECK (item_tier IN ('bronze', 'silver', 'gold')),
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'active', 'deprecated')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE item_components (
parent_sellable_item_id BIGINT NOT NULL REFERENCES sellable_items(sellable_item_id),
child_sellable_item_id BIGINT NOT NULL REFERENCES sellable_items(sellable_item_id),
component_qty NUMERIC(12, 3) NOT NULL,
relation_type TEXT NOT NULL
CHECK (relation_type IN ('contains_unit', 'contains_bundle', 'contains_carton')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (parent_sellable_item_id, child_sellable_item_id, relation_type)
);
CREATE TABLE item_aliases (
alias_id BIGSERIAL PRIMARY KEY,
sellable_item_id BIGINT NOT NULL REFERENCES sellable_items(sellable_item_id),
alias_type TEXT NOT NULL
CHECK (alias_type IN ('invoice_pn', 'channel_name', 'crowd_name', 'manual_alias')),
alias_text TEXT NOT NULL,
merchant_code TEXT,
source_channel TEXT,
store_tax_id TEXT,
valid_from DATE,
valid_to DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE item_barcodes (
sellable_item_id BIGINT NOT NULL REFERENCES sellable_items(sellable_item_id),
barcode TEXT NOT NULL,
barcode_role TEXT NOT NULL
CHECK (barcode_role IN ('canonical', 'supporting', 'historical', 'suspect')),
barcode_class TEXT,
source_confidence NUMERIC(6, 4),
valid_from DATE,
valid_to DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (sellable_item_id, barcode, barcode_role)
);
CREATE TABLE sellable_item_images (
sellable_item_id BIGINT NOT NULL REFERENCES sellable_items(sellable_item_id),
image_hash TEXT NOT NULL,
image_url TEXT,
image_role TEXT NOT NULL
CHECK (image_role IN ('primary', 'supporting', 'historical', 'suspect')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (sellable_item_id, image_hash)
);
CREATE TABLE invoice_lines (
invoice_line_id BIGSERIAL PRIMARY KEY,
merchant_code TEXT NOT NULL,
channel_code TEXT,
store_name TEXT,
store_tax_id TEXT,
invoice_id TEXT,
raw_pn TEXT NOT NULL,
invoice_date DATE,
quantity NUMERIC(12, 3),
amount NUMERIC(12, 2),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE invoice_name_clusters (
cluster_id BIGSERIAL PRIMARY KEY,
merchant_code TEXT NOT NULL,
channel_code TEXT,
normalized_pn TEXT NOT NULL,
representative_pn TEXT NOT NULL,
brand_hint TEXT,
category_hint TEXT,
cluster_status TEXT NOT NULL DEFAULT 'open'
CHECK (cluster_status IN ('open', 'confirmed', 'merged', 'rejected')),
line_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (merchant_code, normalized_pn)
);
CREATE TABLE cluster_members (
cluster_id BIGINT NOT NULL REFERENCES invoice_name_clusters(cluster_id),
invoice_line_id BIGINT NOT NULL UNIQUE REFERENCES invoice_lines(invoice_line_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (cluster_id, invoice_line_id)
);
CREATE TABLE evidence_observations (
observation_id BIGSERIAL PRIMARY KEY,
source_type TEXT NOT NULL
CHECK (source_type IN ('invoice', 'channel', 'crowd', 'barcode', 'image', 'history', 'manual')),
source_channel TEXT,
source_record_id TEXT,
merchant_code TEXT,
store_name TEXT,
store_tax_id TEXT,
raw_name TEXT,
normalized_name TEXT,
brand_hint TEXT,
category_hint TEXT,
barcode TEXT,
barcode_class TEXT,
risk_flags JSONB NOT NULL DEFAULT '[]'::jsonb,
image_url TEXT,
image_hash TEXT,
package_form TEXT,
net_content_value NUMERIC(12, 3),
net_content_uom TEXT,
source_url TEXT,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
valid_from DATE,
valid_to DATE,
observed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE mapping_candidates (
candidate_id BIGSERIAL PRIMARY KEY,
cluster_id BIGINT NOT NULL REFERENCES invoice_name_clusters(cluster_id),
sellable_item_id BIGINT NOT NULL REFERENCES sellable_items(sellable_item_id),
generated_by TEXT NOT NULL
CHECK (generated_by IN ('rule', 'ml', 'history', 'crowd', 'manual')),
score NUMERIC(6, 4) NOT NULL,
proposed_mapping_tier TEXT NOT NULL DEFAULT 'bronze'
CHECK (proposed_mapping_tier IN ('bronze', 'silver', 'gold')),
candidate_status TEXT NOT NULL DEFAULT 'proposed'
CHECK (candidate_status IN ('proposed', 'shortlisted', 'rejected', 'confirmed')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (cluster_id, sellable_item_id)
);
CREATE TABLE mapping_candidate_evidence (
candidate_id BIGINT NOT NULL REFERENCES mapping_candidates(candidate_id),
observation_id BIGINT NOT NULL REFERENCES evidence_observations(observation_id),
evidence_type TEXT NOT NULL
CHECK (evidence_type IN ('name', 'barcode', 'image', 'channel', 'history', 'merchant', 'brand', 'category', 'store', 'time', 'manual')),
evidence_score NUMERIC(6, 4) NOT NULL,
is_supporting BOOLEAN NOT NULL DEFAULT TRUE,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (candidate_id, observation_id, evidence_type)
);
CREATE TABLE mapping_decisions (
decision_id BIGSERIAL PRIMARY KEY,
cluster_id BIGINT NOT NULL UNIQUE REFERENCES invoice_name_clusters(cluster_id),
sellable_item_id BIGINT REFERENCES sellable_items(sellable_item_id),
decision_type TEXT NOT NULL
CHECK (decision_type IN ('auto_confirmed', 'review_confirmed', 'rejected', 'needs_new_item', 'needs_more_evidence')),
mapping_tier TEXT
CHECK (mapping_tier IN ('bronze', 'silver', 'gold')),
reviewer TEXT,
decision_note TEXT,
decided_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE review_tasks (
review_task_id BIGSERIAL PRIMARY KEY,
cluster_id BIGINT REFERENCES invoice_name_clusters(cluster_id),
candidate_id BIGINT REFERENCES mapping_candidates(candidate_id),
task_type TEXT NOT NULL
CHECK (task_type IN ('candidate_confirm', 'new_item_triage', 'barcode_confirm', 'image_confirm')),
task_status TEXT NOT NULL DEFAULT 'open'
CHECK (task_status IN ('open', 'in_progress', 'completed', 'cancelled')),
assigned_to TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE TABLE review_answers (
review_answer_id BIGSERIAL PRIMARY KEY,
review_task_id BIGINT NOT NULL REFERENCES review_tasks(review_task_id),
answered_by TEXT NOT NULL,
answer_code TEXT NOT NULL,
answer_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
answered_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_sellable_items_base_item_id
ON sellable_items (base_item_id);
CREATE INDEX idx_item_aliases_sellable_item_id
ON item_aliases (sellable_item_id);
CREATE INDEX idx_item_aliases_invoice_lookup
ON item_aliases (alias_type, merchant_code, alias_text);
CREATE INDEX idx_item_barcodes_barcode
ON item_barcodes (barcode);
CREATE INDEX idx_sellable_item_images_hash
ON sellable_item_images (image_hash);
CREATE INDEX idx_invoice_lines_merchant_raw_pn
ON invoice_lines (merchant_code, raw_pn);
CREATE INDEX idx_invoice_lines_store_tax_id
ON invoice_lines (store_tax_id, invoice_date);
CREATE INDEX idx_invoice_name_clusters_merchant_normalized_pn
ON invoice_name_clusters (merchant_code, normalized_pn);
CREATE INDEX idx_evidence_observations_barcode
ON evidence_observations (barcode);
CREATE INDEX idx_evidence_observations_normalized_name
ON evidence_observations (normalized_name);
CREATE INDEX idx_evidence_observations_store_tax_id
ON evidence_observations (store_tax_id, observed_at);
CREATE INDEX idx_mapping_candidates_cluster_score
ON mapping_candidates (cluster_id, score DESC);
CREATE INDEX idx_review_tasks_status
ON review_tasks (task_status, task_type);
COMMIT;