# Data Model — Mangolê

> **Data:** 2026-07-21 (actualizado FASE 15/16)
> **Base:** Auditoria real + Arquitectura de Domínio aprovada + implementação
> **Propósito:** Modelo final da base de dados (actualizado após FASE 15/16)

---

## ⚠️ Diferenças entre este documento e a realidade actual (FASE 15/16)

| Secção | Documentado | Realidade |
|--------|-------------|-----------|
| `business_inquiries.status` | pending, read, replied, archived | pending, **new**, read, **in_review**, **contacted**, **converted**, archived |
| `business_inquiries` | sem `province_id`, sem `business_type` | tem `province_id` (FK) e `business_type` (enum BusinessType) |
| `campaigns` | "MANTER — sem alterações" | acrescentado `image` (migration 000016), `seo_*` campos |
| `news_articles` | "MANTER — sem alterações" | acrescentado `seo_title`, `seo_description` (migration 000017) |
| `partners` | "MANTER — sem alterações" | acrescentado `slug`, `seo_title`, `seo_description` (migration 000018) |
| `vacancies` | "MANTER — sem alterações" | acrescentado `slug`, `seo_title`, `seo_description` (migration 000019) |
| `team_members.category` | ENUM('board','leadership') | VARCHAR(30) (convertido na migration 000012) |
| Tabelas a mais | 22 tabelas | **24 tabelas** — + `opportunities` (migration 000020) |
| Conflito 1 (migrations) | "Migration de sincronização" | **Foundation migration** `2026_07_20_230000` com 12 tabelas + `Schema::hasTable()` |
| Entries duplicadas | `products.id` começa em 11 | Seeds produzem IDs sequenciais em fresh install |
| Entries duplicadas | Nenhuma categoria "Azeite" | Categoria "Azeite e Óleos" adicionada no CategorySeeder |

### Novas tabelas não documentadas originalmente
- **`opportunities`** (migration 000020) — Conteúdo editorial adicional (oportunidades de negócio/investimento)

### Tabelas criadas pela foundation migration
`stores`, `products`, `categories`, `campaigns`, `campaign_assets`, `campaign_ctas`, `news_articles`, `partners`, `vacancies`, `settings`, `seo_metadata`, `team_members`

### Tabelas criadas por migrations individuais
`users`, `cache`, `jobs`, `provinces`, `municipalities`, `store_hours`, `store_services`, `brands`, `product_images`, `business_inquiries`, `opportunities`

---

## Legenda de Acrónimos

| Símbolo | Significado |
|---------|-------------|
| PK | Primary Key |
| FK | Foreign Key |
| UQ | Unique |
| IDX | Index (non-unique) |
| AI | Auto Increment |
| req | NOT NULL (required) |
| opt | NULLable (optional) |

---

## Classificação das Tabelas Existentes

| Tabela Actual | Decisão | Acção |
|---------------|---------|-------|
| `settings` | **ADAPTAR** | Adicionar `is_active` e `description`; ajustar unique |
| `stores` | **ADAPTAR** | Adicionar FK `province_id` e `municipality_id` |
| `store_messages` | **SUBSTITUIR** | Renomear para `business_inquiries` e estender |
| `product_families` | **ADAPTAR** | Renomear para `categories` (os dados são categorias) |
| `products` | **ADAPTAR** | Adicionar FK `category_id`, `brand_id` |
| `campaigns` | **MANTER** | Sem alterações |
| `campaign_assets` | **MANTER** | Sem alterações |
| `campaign_ctas` | **MANTER** | Sem alterações (não listada mas necessária) |
| `news_articles` | **MANTER** | Sem alterações |
| `homepage_blocks` | **ABANDONAR** | Substituído por secções fixas em welcome.blade.php |
| `partners` | **MANTER** | Sem alterações |
| `vacancies` | **MANTER** | Sem alterações |
| `seo_metadata` | **MANTER** | Sem alterações |
| `team_members` | **MANTER** | Sem alterações |
| `users` | **MANTER** | Apenas adicionar traits Filament (sem alteração de schema) |

---

## Novas Tabelas Propostas (7)

| Tabela | Origem | Justificação |
|--------|--------|--------------|
| `provinces` | Nova (lookup) | Normalizar `stores.province_code` |
| `municipalities` | Nova (lookup) | Normalizar `stores.municipality` |
| `store_hours` | Extraída de `stores.opening_hours` | Normalizar JSON para rows relacionais |
| `store_services` | Extraída de `stores.services` | Normalizar JSON para rows relacionais |
| `brands` | Nova | Separar marca do produto (ex: "Tempero Boa Mesa") |
| `product_images` | Extraída de `products.image` | Suportar múltiplas imagens por produto |
| `business_inquiries` | Substitui `store_messages` | Renomear e estender para leads B2B |

---

## A. settings

**Finalidade:** Configurações chave-valor agrupadas por contexto.
**Decisão sobre tabela existente:** ADAPTAR — manter dados (6 registos), adicionar colunas.

### Schema

| Coluna | Tipo | Null | Default | Chave | Descrição |
|--------|------|------|---------|-------|-----------|
| `id` | bigint unsigned | NO | — | PK, AI | |
| `group` | varchar(50) | NO | `'general'` | IDX | Grupo de configuração |
| `key` | varchar(100) | NO | — | IDX | Nome da chave |
| `value` | text | YES | NULL | — | Valor |
| `description` | varchar(255) | YES | NULL | — | **NOVO** — descrição amigável |
| `is_active` | tinyint(1) | NO | 1 | IDX | **NOVO** — activo/inactivo |
| `created_at` | timestamp | YES | NULL | — | |
| `updated_at` | timestamp | YES | NULL | — | |

### Índices
- **PK:** `id`
- **UQ:** `(group, key)` — já existe como `settings_group_key_unique`
- **IDX:** `group`, `is_active`

### Estados
- Activo (`is_active = 1`)
- Inactivo (`is_active = 0`)

### Dados existentes
- 6 registos nos grupos `home` e `about` — migrar sem alterações
- `description` será NULL para registos existentes (opcional)

### Soft delete: Não

---

## B. stores

**Finalidade:** Representa cada loja física da rede Mangolê.
**Decisão sobre tabela existente:** ADAPTAR — manter dados (9 registos), adicionar FKs.

### Schema

| Coluna | Tipo | Null | Default | Chave | Descrição |
|--------|------|------|---------|-------|-----------|
| `id` | bigint unsigned | NO | — | PK, AI | |
| `name` | varchar(150) | NO | — | — | Nome comercial |
| `slug` | varchar(160) | NO | — | UQ | URL amigável |
| `type` | varchar(30) | YES | NULL | — | `hiper`, `mix` (já existente) |
| `description` | text | YES | NULL | — | |
| `province_id` | bigint unsigned | YES | NULL | FK | **NOVO** — ref. `provinces.id` |
| `municipality_id` | bigint unsigned | YES | NULL | FK | **NOVO** — ref. `municipalities.id` |
| `neighborhood` | varchar(100) | YES | NULL | — | |
| `address_line` | varchar(255) | YES | NULL | — | |
| `reference_point` | varchar(255) | YES | NULL | — | |
| `latitude` | decimal(10,8) | YES | NULL | IDX | |
| `longitude` | decimal(11,8) | YES | NULL | IDX | |
| `phone` | varchar(30) | YES | NULL | — | |
| `whatsapp` | varchar(30) | YES | NULL | — | |
| `email` | varchar(150) | YES | NULL | — | |
| `website` | varchar(255) | YES | NULL | — | |
| `photo` | varchar(255) | YES | NULL | — | URL da foto principal |
| `is_active` | tinyint(1) | NO | 1 | IDX | |
| `status` | varchar(30) | YES | `'open'` | — | `open`, `closed`, `renovation` |
| `sort_order` | int unsigned | NO | 0 | IDX | |
| `deleted_at` | timestamp | YES | NULL | — | Soft delete |
| `created_at` | timestamp | YES | NULL | — | |
| `updated_at` | timestamp | YES | NULL | — | |

### Índices
- **PK:** `id`
- **UQ:** `slug`
- **FK:** `province_id` → `provinces.id`, `municipality_id` → `municipalities.id`
- **IDX:** `is_active`, `sort_order`, `latitude`, `longitude`, `province_id`

### Estados
| Campo | Valores |
|-------|---------|
| `is_active` | 1 (visível), 0 (oculta) |
| `status` | `open`, `closed`, `renovation` |
| `deleted_at` | NULL (activa), timestamp (removida) |

### Dados existentes
- 9 registos preservados
- `province_code` (ex: LUA, BGO) → mapeado para `province_id` via lookup
- `municipality` → mapeado para `municipality_id` via lookup
- Colunas `province_code`, `municipality` removidas após migração

### Soft delete: Sim (já implementado)

---

## C. provinces (NOVA)

**Finalidade:** Lookup de províncias de Angola (normalização geográfica).

### Schema

| Coluna | Tipo | Null | Default | Chave | Descrição |
|--------|------|------|---------|-------|-----------|
| `id` | bigint unsigned | NO | — | PK, AI | |
| `code` | varchar(3) | NO | — | UQ | Código (LUA, BGO, etc.) |
| `name` | varchar(100) | NO | — | — | Nome (Luanda, Bengo, etc.) |
| `created_at` | timestamp | YES | NULL | — | |
| `updated_at` | timestamp | YES | NULL | — | |

### Índices
- **PK:** `id`
- **UQ:** `code`

### Dados iniciais (seed)
| code | name |
|------|------|
| BGO | Bengo |
| BGU | Benguela |
| BIE | Bié |
| CAB | Cabinda |
| CNN | Cunene |
| HUA | Huambo |
| HUI | Huíla |
| LUA | Luanda |
| LNO | Lunda Norte |
| LSU | Lunda Sul |
| MAL | Malanje |
| MOC | Moxico |
| NAM | Namibe |
| UIG | Uíge |
| ZAI | Zaire |

### Soft delete: Não

---

## D. municipalities (NOVA)

**Finalidade:** Lookup de municípios por província.

### Schema

| Coluna | Tipo | Null | Default | Chave | Descrição |
|--------|------|------|---------|-------|-----------|
| `id` | bigint unsigned | NO | — | PK, AI | |
| `province_id` | bigint unsigned | NO | — | FK | Ref. `provinces.id` |
| `name` | varchar(100) | NO | — | — | Nome do município |
| `created_at` | timestamp | YES | NULL | — | |
| `updated_at` | timestamp | YES | NULL | — | |

### Índices
- **PK:** `id`
- **FK:** `province_id` → `provinces.id`
- **UQ:** `(province_id, name)`
- **IDX:** `province_id`

### Dados iniciais (seed — apenas municípios existentes nas stores)
| province | municipality |
|----------|-------------|
| LUA | Belas |
| LUA | Cazenga |
| LUA | Cacuaco |
| LUA | Kilamba Kiaxi |
| LUA | Viana |
| BGO | Dande |

### Soft delete: Não

---

## E. store_hours (NOVA)

**Finalidade:** Horários de funcionamento por loja e dia da semana.
**Origem:** Extraído de `stores.opening_hours` (JSON).

### Schema

| Coluna | Tipo | Null | Default | Chave | Descrição |
|--------|------|------|---------|-------|-----------|
| `id` | bigint unsigned | NO | — | PK, AI | |
| `store_id` | bigint unsigned | NO | — | FK | Ref. `stores.id` |
| `day_of_week` | tinyint unsigned | NO | — | IDX | 0 (Dom) a 6 (Sáb) |
| `is_closed` | tinyint(1) | NO | 0 | — | Fechado neste dia |
| `opens_at` | time | YES | NULL | — | Hora de abertura |
| `closes_at` | time | YES | NULL | — | Hora de fecho |
| `created_at` | timestamp | YES | NULL | — | |
| `updated_at` | timestamp | YES | NULL | — | |

### Índices
- **PK:** `id`
- **FK:** `store_id` → `stores.id` ON DELETE CASCADE
- **UQ:** `(store_id, day_of_week)` — um registo por dia
- **IDX:** `store_id`

### Estados
- `is_closed = 1`: loja fechada neste dia (opens_at/closes_at NULL)
- `is_closed = 0`: aberto com horário definido

### Dados existentes
- Nenhum — tabela vazia (opening_hours em JSON nunca foi populado)

### Soft delete: Não

---

## F. store_services (NOVA)

**Finalidade:** Serviços disponíveis por loja.
**Origem:** Extraído de `stores.services` (JSON).

### Schema

| Coluna | Tipo | Null | Default | Chave | Descrição |
|--------|------|------|---------|-------|-----------|
| `id` | bigint unsigned | NO | — | PK, AI | |
| `store_id` | bigint unsigned | NO | — | FK | Ref. `stores.id` |
| `service` | varchar(100) | NO | — | — | Nome do serviço (ex: "Talho", "Padaria") |
| `icon` | varchar(50) | YES | NULL | — | Ícone (classe CSS ou nome) |
| `sort_order` | smallint unsigned | NO | 0 | IDX | Ordem de exibição |
| `created_at` | timestamp | YES | NULL | — | |
| `updated_at` | timestamp | YES | NULL | — | |

### Índices
- **PK:** `id`
- **FK:** `store_id` → `stores.id` ON DELETE CASCADE
- **UQ:** `(store_id, service)`
- **IDX:** `store_id`, `sort_order`

### Dados existentes
- Nenhum — tabela vazia (services em JSON nunca foi populado)

### Soft delete: Não

---

## G. categories

**Finalidade:** Categorias/famílias de produtos (ex: Açúcar, Arroz, Farinhas).
**Decisão sobre tabela existente:** ADAPTAR — renomear `product_families` para `categories`.

### Schema

| Coluna | Tipo | Null | Default | Chave | Descrição |
|--------|------|------|---------|-------|-----------|
| `id` | bigint unsigned | NO | — | PK, AI | |
| `name` | varchar(100) | NO | — | — | Nome da categoria |
| `slug` | varchar(120) | NO | — | UQ | URL amigável |
| `image` | varchar(255) | YES | NULL | — | Imagem representativa |
| `show_in_hero` | tinyint(1) | NO | 1 | IDX | Exibir na vitrine homepage |
| `sort_order` | int unsigned | NO | 0 | IDX | |
| `is_active` | tinyint(1) | NO | 1 | IDX | |
| `created_at` | timestamp | YES | NULL | — | |
| `updated_at` | timestamp | YES | NULL | — | |

### Índices
- **PK:** `id`
- **UQ:** `slug`
- **IDX:** `show_in_hero`, `sort_order`, `is_active`

### Estados
- `is_active`: 1 (visível), 0 (oculta)
- `show_in_hero`: 1 (vitrine homepage), 0 (apenas na página de produtos)

### Dados existentes
- 6 registos migrados de `product_families` sem alterações

| id | name | slug | show_in_hero | sort_order | is_active |
|----|------|------|:-----------:|:----------:|:--------:|
| 1 | Açúcar | acucar | 1 | 1 | 1 |
| 2 | Arroz | arroz | 1 | 2 | 1 |
| 3 | Farinhas | farinhas | 1 | 3 | 1 |
| 4 | Grãos | graos | 1 | 4 | 1 |
| 5 | Milho | milho | 1 | 5 | 1 |
| 6 | Óleo de Palma | oleo-de-palma | 1 | 6 | 1 |

### Soft delete: Não

---

## H. brands (NOVA)

**Finalidade:** Marcas de produtos (ex: "Tempero Boa Mesa", "Coca-Cola", "Nestlé").

### Schema

| Coluna | Tipo | Null | Default | Chave | Descrição |
|--------|------|------|---------|-------|-----------|
| `id` | bigint unsigned | NO | — | PK, AI | |
| `name` | varchar(150) | NO | — | — | Nome da marca |
| `slug` | varchar(170) | NO | — | UQ | URL amigável |
| `logo` | varchar(255) | YES | NULL | — | Logótipo da marca |
| `is_active` | tinyint(1) | NO | 1 | IDX | |
| `created_at` | timestamp | YES | NULL | — | |
| `updated_at` | timestamp | YES | NULL | — | |

### Índices
- **PK:** `id`
- **UQ:** `slug`
- **IDX:** `is_active`

### Dados existentes
- Nenhum — será populado durante a migração de produtos
- Marca identificável nos produtos actuais: "Tempero Boa Mesa"

### Soft delete: Não

---

## I. products

**Finalidade:** Catálogo de produtos.
**Decisão sobre tabela existente:** ADAPTAR — adicionar FKs, manter dados (5 registos).

### Schema

| Coluna | Tipo | Null | Default | Chave | Descrição |
|--------|------|------|---------|-------|-----------|
| `id` | bigint unsigned | NO | — | PK, AI | |
| `category_id` | bigint unsigned | YES | NULL | FK | **NOVO** — ref. `categories.id` |
| `brand_id` | bigint unsigned | YES | NULL | FK | **NOVO** — ref. `brands.id` |
| `name` | varchar(200) | NO | — | — | Nome do produto |
| `slug` | varchar(220) | NO | — | UQ | URL amigável |
| `description` | text | YES | NULL | — | |
| `price` | decimal(10,2) | NO | — | — | Preço actual |
| `original_price` | decimal(10,2) | YES | NULL | — | Preço original (promocional) |
| `badge` | varchar(100) | YES | NULL | — | Texto do badge (ex: "Preço Bomba!") |
| `badge_color` | varchar(30) | YES | NULL | — | Cor do badge |
| `image` | varchar(255) | YES | NULL | — | **Manter** — imagem principal |
| `is_active` | tinyint(1) | NO | 1 | IDX | |
| `featured` | tinyint(1) | NO | 0 | IDX | Produto em destaque |
| `sort_order` | int unsigned | NO | 0 | IDX | |
| `deleted_at` | timestamp | YES | NULL | — | Soft delete |
| `created_at` | timestamp | YES | NULL | — | |
| `updated_at` | timestamp | YES | NULL | — | |

### Índices
- **PK:** `id`
- **UQ:** `slug`
- **FK:** `category_id` → `categories.id`, `brand_id` → `brands.id`
- **IDX:** `is_active`, `featured`, `sort_order`, `category_id`, `brand_id`

### Estados
| Campo | Valores |
|-------|---------|
| `is_active` | 1 (visível), 0 (oculto) |
| `featured` | 1 (destaque), 0 (normal) |
| `deleted_at` | NULL (activo), timestamp (removido) |
| `original_price` | Se preenchido: produto em promoção |

### Dados existentes
- 5 registos preservados
- `category` (string, ex: "Azeite") → mapeado para `category_id`
- `category` será removida após migração

### Dados actuais
| id | name | price | original_price | badge | category actual |
|----|------|-------|---------------|-------|-----------------|
| 11 | Azeite Tempero Boa Mesa Lata 500ml | 1.850 | 2.350 | Preço Bomba! | Azeite |
| 12 | Azeite Tempero Boa Mesa Plástico 0.5L | 950 | 1.200 | Preço Bomba! | Azeite |
| 13 | Azeite Tempero Boa Mesa Vidro 1L | 1.650 | 2.100 | Destaque | Azeite |
| 14 | Azeite Tempero Boa Mesa Plástico 3L | 3.950 | 4.800 | Economia Família | Azeite |
| 15 | Azeite Tempero Boa Mesa Plástico 5L | 5.950 | 7.200 | Pack Grosso | Azeite |

**Problema:** Nenhuma categoria actual corresponde a "Azeite". Será necessário criar uma categoria "Azeite" (ou similar) em `categories`.

### Soft delete: Sim (já implementado)

---

## J. product_images (NOVA)

**Finalidade:** Galeria de imagens por produto (múltiplas fotos, ângulos, variações).

### Schema

| Coluna | Tipo | Null | Default | Chave | Descrição |
|--------|------|------|---------|-------|-----------|
| `id` | bigint unsigned | NO | — | PK, AI | |
| `product_id` | bigint unsigned | NO | — | FK | Ref. `products.id` |
| `url` | varchar(500) | NO | — | — | Caminho da imagem |
| `alt` | varchar(255) | YES | NULL | — | Texto alternativo |
| `is_primary` | tinyint(1) | NO | 0 | IDX | Imagem principal |
| `sort_order` | int unsigned | NO | 0 | IDX | |
| `created_at` | timestamp | YES | NULL | — | |
| `updated_at` | timestamp | YES | NULL | — | |

### Índices
- **PK:** `id`
- **FK:** `product_id` → `products.id` ON DELETE CASCADE
- **IDX:** `product_id`, `is_primary`, `sort_order`

### Dados existentes
- Nenhum — `products.image` mantém-se como imagem principal
- `product_images` serve para imagens adicionais (galeria)

### Soft delete: Não

---

## K. business_inquiries

**Finalidade:** Mensagens de contacto, leads B2B e pedidos de informação.
**Decisão sobre tabela existente:** SUBSTITUIR — renomear e estender `store_messages`.

### Schema

| Coluna | Tipo | Null | Default | Chave | Descrição |
|--------|------|------|---------|-------|-----------|
| `id` | bigint unsigned | NO | — | PK, AI | |
| `store_id` | bigint unsigned | YES | NULL | FK | Ref. `stores.id` (opcional) |
| `name` | varchar(150) | NO | — | — | Nome do contacto |
| `email` | varchar(150) | NO | — | — | Email |
| `phone` | varchar(50) | YES | NULL | — | |
| `company` | varchar(150) | YES | NULL | — | **NOVO** — empresa (B2B) |
| `subject` | varchar(200) | YES | NULL | — | **NOVO** — assunto |
| `message` | text | NO | — | — | |
| `type` | varchar(30) | NO | `'general'` | IDX | **NOVO** — `general`, `b2b`, `store`, `partnership` |
| `status` | varchar(20) | NO | `'pending'` | IDX | `pending`, `read`, `replied`, `archived` |
| `created_at` | timestamp | YES | NULL | — | |
| `updated_at` | timestamp | YES | NULL | — | |

### Índices
- **PK:** `id`
- **FK:** `store_id` → `stores.id`
- **IDX:** `status`, `type`, `store_id`

### Estados
| Campo | Valores |
|-------|---------|
| `type` | `general` (geral), `b2b` (empresarial), `store` (loja específica), `partnership` (parceria) |
| `status` | `pending`, `read`, `replied`, `archived` |

### Dados existentes
- 2 registos migrados de `store_messages` (id 14 e 15)
- `store_id = NULL` mantém-se como NULL
- `type = 'general'` para registos existentes
- `company = NULL` para registos existentes
- `subject = NULL` para registos existentes

### Soft delete: Não

---

## L. news_articles

**Finalidade:** Artigos de notícias e conteúdo editorial.
**Decisão sobre tabela existente:** MANTER — sem alterações.

### Schema

| Coluna | Tipo | Null | Default | Chave | Descrição |
|--------|------|------|---------|-------|-----------|
| `id` | bigint unsigned | NO | — | PK, AI | |
| `title` | varchar(200) | NO | — | — | |
| `slug` | varchar(220) | NO | — | UQ | |
| `excerpt` | text | YES | NULL | — | Resumo |
| `content` | text | NO | — | — | Conteúdo completo |
| `image` | varchar(255) | YES | NULL | — | Imagem destacada |
| `category` | varchar(100) | NO | — | — | Categoria editorial |
| `author` | varchar(150) | YES | NULL | — | |
| `published_at` | timestamp | YES | NULL | — | Data de publicação |
| `status` | varchar(20) | NO | `'draft'` | IDX | `draft`, `published`, `archived` |
| `reading_time` | smallint unsigned | YES | NULL | — | Minutos estimados |
| `featured` | tinyint(1) | NO | 0 | IDX | |
| `created_at` | timestamp | YES | NULL | — | |
| `updated_at` | timestamp | YES | NULL | — | |

### Índices
- **PK:** `id`
- **UQ:** `slug`
- **IDX:** `status`, `featured`

### Estados
| Campo | Valores |
|-------|---------|
| `status` | `draft`, `published`, `archived` |
| `featured` | 1 (destaque), 0 (normal) |
| `published_at` | Se NULL: rascunho não publicado |

### Soft delete: Recomenda-se adicionar `deleted_at` (consistência com products/stores)

---

## M. campaigns

**Finalidade:** Campanhas promocionais com período, prioridade e SEO.
**Decisão sobre tabela existente:** MANTER — sem alterações.

### Schema

| Coluna | Tipo | Null | Default | Chave | Descrição |
|--------|------|------|---------|-------|-----------|
| `id` | bigint unsigned | NO | — | PK, AI | |
| `name` | varchar(150) | NO | — | — | |
| `slug` | varchar(180) | NO | — | UQ | |
| `objective` | varchar(255) | NO | — | — | Objectivo da campanha |
| `status` | varchar(20) | NO | — | IDX | `draft`, `published`, `archived` |
| `starts_at` | timestamp | YES | NULL | IDX | |
| `ends_at` | timestamp | YES | NULL | IDX | |
| `priority` | int unsigned | NO | 0 | IDX | |
| `is_pinned` | tinyint(1) | NO | 0 | IDX | Fixa independentemente da data |
| `seo_title` | varchar(255) | YES | NULL | — | |
| `seo_description` | varchar(500) | YES | NULL | — | |
| `seo_keywords` | text | YES | NULL | — | |
| `seo_canonical` | varchar(500) | YES | NULL | — | |
| `seo_og_title` | varchar(255) | YES | NULL | — | |
| `seo_og_description` | varchar(500) | YES | NULL | — | |
| `seo_twitter_title` | varchar(255) | YES | NULL | — | |
| `seo_twitter_description` | varchar(500) | YES | NULL | — | |
| `created_at` | timestamp | YES | NULL | — | |
| `updated_at` | timestamp | YES | NULL | — | |

### Índices
- **PK:** `id`
- **UQ:** `slug`
- **IDX:** `status`, `starts_at`, `ends_at`, `priority`, `is_pinned`

### Estados
| Campo | Valores |
|-------|---------|
| `status` | `draft`, `published`, `archived` |
| `is_pinned` | 1 (fixa), 0 (normal) |
| Período | `starts_at`/`ends_at` definem janela de exibição |

### Soft delete: Recomenda-se adicionar `deleted_at`

---

## N. campaign_assets

**Finalidade:** Assets multimédia por campanha.
**Decisão sobre tabela existente:** MANTER — sem alterações.

### Schema

| Coluna | Tipo | Null | Default | Chave | Descrição |
|--------|------|------|---------|-------|-----------|
| `id` | bigint unsigned | NO | — | PK, AI | |
| `campaign_id` | bigint unsigned | NO | — | FK | Ref. `campaigns.id` |
| `purpose` | varchar(30) | NO | — | — | `hero`, `banner`, `thumbnail` |
| `file_path` | varchar(500) | NO | — | — | |
| `alt_text` | varchar(255) | NO | — | — | |
| `width` | int unsigned | NO | — | — | |
| `height` | int unsigned | NO | — | — | |
| `mime_type` | varchar(100) | NO | — | — | |
| `size` | bigint unsigned | NO | — | — | Bytes |
| `sort_order` | int unsigned | NO | 0 | — | |
| `created_at` | timestamp | YES | NULL | — | |
| `updated_at` | timestamp | YES | NULL | — | |

### Índices
- **PK:** `id`
- **FK:** `campaign_id` → `campaigns.id` ON DELETE CASCADE
- **UQ:** `(campaign_id, purpose, sort_order)`
- **IDX:** `campaign_id`

### Soft delete: Não

---

## O. campaign_ctas

**Finalidade:** Calls-to-action por campanha.
**Decisão sobre tabela existente:** MANTER — sem alterações.

### Schema

| Coluna | Tipo | Null | Default | Chave | Descrição |
|--------|------|------|---------|-------|-----------|
| `id` | bigint unsigned | NO | — | PK, AI | |
| `campaign_id` | bigint unsigned | NO | — | FK | Ref. `campaigns.id` |
| `label` | varchar(100) | NO | — | — | Texto do botão |
| `destination` | varchar(500) | NO | — | — | URL de destino |
| `sort_order` | int unsigned | NO | 0 | — | |
| `created_at` | timestamp | YES | NULL | — | |
| `updated_at` | timestamp | YES | NULL | — | |

### Índices
- **PK:** `id`
- **FK:** `campaign_id` → `campaigns.id` ON DELETE CASCADE
- **UQ:** `(campaign_id, sort_order)`

### Soft delete: Não

---

## P. partners

**Finalidade:** Parceiros institucionais e comerciais.
**Decisão sobre tabela existente:** MANTER — sem alterações.

### Schema

| Coluna | Tipo | Null | Default | Chave | Descrição |
|--------|------|------|---------|-------|-----------|
| `id` | bigint unsigned | NO | — | PK, AI | |
| `name` | varchar(150) | NO | — | — | |
| `logo` | varchar(255) | YES | NULL | — | |
| `segment` | varchar(100) | YES | NULL | — | Segmento de actuação |
| `level` | varchar(30) | NO | `'standard'` | — | `standard`, `gold`, `platinum` |
| `href` | varchar(255) | YES | NULL | — | URL do parceiro |
| `since` | int | YES | NULL | — | Ano de parceria |
| `display_order` | int unsigned | NO | 0 | IDX | |
| `is_active` | tinyint(1) | NO | 1 | IDX | |
| `created_at` | timestamp | YES | NULL | — | |
| `updated_at` | timestamp | YES | NULL | — | |

### Índices
- **PK:** `id`
- **IDX:** `display_order`, `is_active`

### Estados
- `is_active`: 1 (visível), 0 (oculto)
- `level`: `standard`, `gold`, `platinum`

### Soft delete: Recomenda-se adicionar `deleted_at`

---

## Q. vacancies

**Finalidade:** Vagas de emprego.
**Decisão sobre tabela existente:** MANTER — sem alterações.

### Schema

| Coluna | Tipo | Null | Default | Chave | Descrição |
|--------|------|------|---------|-------|-----------|
| `id` | bigint unsigned | NO | — | PK, AI | |
| `title` | varchar(200) | NO | — | — | |
| `area` | varchar(100) | NO | — | — | Departamento/área |
| `location` | varchar(100) | NO | — | — | |
| `type` | varchar(50) | NO | `'full-time'` | — | `full-time`, `part-time`, `internship` |
| `description` | text | NO | — | — | |
| `href` | varchar(255) | YES | NULL | — | URL de candidatura externa |
| `deadline` | date | YES | NULL | — | |
| `remote` | tinyint(1) | NO | 0 | — | |
| `status` | varchar(20) | NO | `'draft'` | IDX | `draft`, `published`, `closed` |
| `created_at` | timestamp | YES | NULL | — | |
| `updated_at` | timestamp | YES | NULL | — | |

### Índices
- **PK:** `id`
- **IDX:** `status`

### Estados
| Campo | Valores |
|-------|---------|
| `status` | `draft`, `published`, `closed` |
| `type` | `full-time`, `part-time`, `internship` |
| `remote` | 1 (remoto/híbrido), 0 (presencial) |

### Soft delete: Recomenda-se adicionar `deleted_at`

---

## R. seo_metadata

**Finalidade:** Metadados SEO por página estática.
**Decisão sobre tabela existente:** MANTER — sem alterações.

### Schema

| Coluna | Tipo | Null | Default | Chave | Descrição |
|--------|------|------|---------|-------|-----------|
| `id` | bigint unsigned | NO | — | PK, AI | |
| `page_name` | varchar(80) | NO | — | UQ | Identificador único (ex: `home`, `about`) |
| `title` | varchar(255) | YES | NULL | — | |
| `description` | varchar(500) | YES | NULL | — | |
| `og_image` | varchar(255) | YES | NULL | — | |
| `canonical` | varchar(500) | YES | NULL | — | |
| `robots` | varchar(100) | YES | NULL | — | `index,follow`, `noindex,nofollow` |
| `keywords` | text | YES | NULL | — | |
| `og_title` | varchar(255) | YES | NULL | — | |
| `og_description` | varchar(500) | YES | NULL | — | |
| `twitter_title` | varchar(255) | YES | NULL | — | |
| `twitter_description` | varchar(500) | YES | NULL | — | |
| `created_at` | timestamp | YES | NULL | — | |
| `updated_at` | timestamp | YES | NULL | — | |

### Índices
- **PK:** `id`
- **UQ:** `page_name`

### Soft delete: Não

---

## S. users (referência)

**Finalidade:** Autenticação no painel Filament.
**Decisão sobre tabela existente:** MANTER — sem alterações de schema (apenas traits PHP).

### Schema

| Coluna | Tipo | Null | Default | Chave | Descrição |
|--------|------|------|---------|-------|-----------|
| `id` | bigint unsigned | NO | — | PK, AI | |
| `name` | varchar(255) | NO | — | — | |
| `email` | varchar(255) | NO | — | UQ | |
| `email_verified_at` | timestamp | YES | NULL | — | |
| `password` | varchar(255) | NO | — | | |
| `remember_token` | varchar(100) | YES | NULL | — | |
| `created_at` | timestamp | YES | NULL | — | |
| `updated_at` | timestamp | YES | NULL | — | |

### Dados existentes
- admin@mangole.ao (Administrador Mangolê)
- marketing@mangole.ao (Marketing Mangolê)

---

## T. team_members (referência)

**Finalidade:** Equipa directiva (board e leadership).
**Decisão sobre tabela existente:** MANTER — sem alterações.

### Schema

| Coluna | Tipo | Null | Default | Chave | Descrição |
|--------|------|------|---------|-------|-----------|
| `id` | bigint unsigned | NO | — | PK, AI | |
| `name` | varchar(200) | NO | — | — | |
| `role` | varchar(200) | NO | — | — | Cargo |
| `category` | enum('board','leadership') | NO | — | — | `board` ou `leadership` |
| `bio` | text | YES | NULL | — | |
| `photo` | varchar(255) | YES | NULL | — | |
| `sort_order` | smallint unsigned | NO | 0 | — | |
| `is_active` | tinyint(1) | NO | 1 | — | |
| `created_at` | timestamp | YES | NULL | — | |
| `updated_at` | timestamp | YES | NULL | — | |

### Dados existentes
- 3 board + 3 leadership = 6 registos

### Nota
- `category` usa ENUM nativo MySQL — será convertido para varchar(30) + validação Laravel na nova migration

---

## Diagrama de Relações Final

```
provinces ────┐
              │ 1:N
              ▼
municipalities ──┐
                 │ 1:N
                 ▼
              stores ────┐
                │        │ 1:N
                │        ▼
                │   store_hours
                │        │
                │   store_services
                │        │
                │ 1:N    ▼
                └── business_inquiries
                
categories ────┐
               │ 1:N
               ▼
            products ────┐
               │         │ 1:N
               │         ▼
               │    product_images
               │
brands ────────┘
         1:N

campaigns ────┐
     │        ├── campaign_assets
     │        └── campaign_ctas
     │
     └── (SEO fields embutidos)

news_articles     partners     vacancies
(independentes)   (indep.)     (indep.)

seo_metadata ──── (lookup por page_name)
settings     ──── (key-value agrupado)
team_members ──── (independente)
users        ──── (apenas Filament auth)
```

---

## Tabelas Abandonadas

| Tabela | Motivo |
|--------|--------|
| `homepage_blocks` | Substituída por secções fixas em `welcome.blade.php` |
| `product_families` | Renomeada para `categories` |
| `store_messages` | Renomeada/estendida para `business_inquiries` |

---

## Colunas Removidas (após migração)

| Tabela | Coluna | Motivo |
|--------|--------|--------|
| `stores` | `province_code` | Substituída por FK `province_id` |
| `stores` | `municipality` | Substituída por FK `municipality_id` |
| `stores` | `opening_hours` (JSON) | Extraída para `store_hours` |
| `stores` | `services` (JSON) | Extraída para `store_services` |
| `products` | `category` (string) | Substituída por FK `category_id` |

---

## Conflitos Identificados

### Conflito 1: Migrations padrão vs tabelas existentes
- **Problema:** As 3 migrations padrão do Laravel novo criam `users`, `cache`, `cache_locks`, `jobs`, `job_batches`, `failed_jobs`, `password_reset_tokens`, `sessions` — todas já existem na BD
- **Solução:** Migration de sincronização que verifica `Schema::hasTable()` antes de criar

### Conflito 2: `products.id` começa em 11 (gap na sequência)
- **Problema:** O auto-increment está em 15, não em 1. Há um gap de IDs.
- **Solução:** Manter como está (não afecta funcionamento). Resetar só se necessário.

### Conflito 3: Nenhuma categoria "Azeite" existe
- **Problema:** Os 5 produtos existentes têm `category = 'Azeite'` mas as `categories` actuais (product_families) são Açúcar, Arroz, Farinhas, Grãos, Milho, Óleo de Palma
- **Solução:** Adicionar categoria "Azeite e Óleos" durante a migração

### Conflito 4: ENUM `team_members.category`
- **Problema:** MySQL ENUM não é portável entre SGBDs
- **Solução:** Migration que altera de ENUM para VARCHAR(30) + validação Laravel

### Conflito 5: `store_messages` com dados de teste
- **Problema:** 2 registos de teste (teste@example.com, maria@teste.com) serão migrados para `business_inquiries`
- **Solução:** Manter como estão (podem ser arquivados após migração)

---

## Resumo de Tabelas

### Existentes que se mantêm (9)
`campaign_assets`, `campaign_ctas`, `campaigns`, `news_articles`, `partners`, `seo_metadata`, `team_members`, `users`, `vacancies`

### Existentes que adaptam (4)
`settings` (+ is_active, description), `stores` (+ FKs), `products` (+ FKs), `product_families` (→ `categories`)

### Existentes que substituem (1)
`store_messages` → `business_inquiries`

### Existentes que abandonam (1)
`homepage_blocks`

### Novas (7)
`provinces`, `municipalities`, `store_hours`, `store_services`, `brands`, `product_images`, `business_inquiries`

---

**Total final: 22 tabelas** (23 actuais − 1 abandonada − 1 renomeada + 1 renomeada + 7 novas − 3 colunas removidas em stores − 1 coluna removida em products)
