Skip to content

Outlet Enrichment

Outlet Enrichment extends the base Outlet model with 12 additional fields for better classification, geographic precision, and operational grouping. This feature enables more granular reporting, territory management, and integration with Indonesian regional data.

The enrichment feature adds classification hierarchy (Channel Group → Channel → Account), operational grouping (SubArea), timezone support, alternative GPS coordinates, and hierarchical location data (Province → City → District → Village).

Key Benefits:

  • Better Classification — 4-level hierarchy for outlet categorization
  • Geographic Precision — Alternative GPS coordinates for venues with poor signal
  • Operational Grouping — SubArea for field team territory assignment
  • Regional Integration — Links to standard Indonesian administrative divisions
  • Timezone Support — Accurate time-based reporting across Indonesia’s timezones

All enrichment fields are additive and backward-compatible. Existing outlets continue to function without enrichment data.

FieldTypeDescriptionNullableDefault
channel_group_idintegerFK to channel_groupsYESNULL
channel_idintegerFK to channelsYESNULL
account_idintegerFK to accountsYESNULL
sub_area_idintegerFK to sub_areasYESNULL
timezonestringTimezone enum: WIB/WITA/WITNOWIB
alt_geo_latfloat64Alternative latitudeNO0
alt_geo_lngfloat64Alternative longitudeNO0
store_village_idintegerFK to reg_villagesYESNULL
store_district_idintegerFK to reg_districtsYESNULL
store_city_idintegerFK to reg_citiesYESNULL
store_province_idintegerFK to reg_provincesYESNULL
remarkstringFree-form notesNO""

The enrichment introduces a 4-level classification hierarchy for outlet categorization:

ChannelGroup (workspace-scoped)
└── Channel (workspace-scoped, FK to ChannelGroup)
└── Account (workspace-scoped, FK to Channel)

Hierarchy Example:

  • ChannelGroup: “Modern Trade”
  • Channel: “Supermarket”
  • Account: “Carrefour”

SubArea operates independently (no hierarchy) — used for operational grouping such as “Jakarta Central”, “Surabaya North”, or “Bandung Metro”.

ScenarioClassification Path
Traditional retailTraditional Trade → Warung → Independent
Modern retailModern Trade → Supermarket → Carrefour
Pharmacy chainModern Trade → Pharmacy → Guardian
HoReCaHoReCa → Restaurant → Local Chain
WholesalerWholesale → Distributor → Regional

The store_province_id, store_city_id, store_district_id, and store_village_id fields reference the Indonesian regional database (merq-indonesian-regional service).

sequenceDiagram
    participant Client
    participant Backend
    participant Regional
    participant DB
    
    Client->>Backend: GET /office/v1/outlets/123
    Backend->>DB: Query outlet with FKs
    DB-->>Backend: Outlet with regional IDs
    Backend->>Regional: GET /regional/provinces/31
    Regional-->>Backend: Province: DKI Jakarta
    Backend->>Regional: GET /regional/cities/3171
    Regional-->>Backend: City: Jakarta Pusat
    Backend-->>Client: Outlet with nested regional objects

Key Design Decisions:

  • Separate Service — Regional data (83k+ villages) runs as independent HTTP service
  • No Cross-DB FKs — Regional IDs stored as plain integers in outlets table
  • HTTP Proxy — Backend proxies requests via internal/platform/regional/client.go
  • Name Resolution — Happens at API response time (not DB join)
  • Graceful Degradation — If regional service unreachable, returns empty names

Set the regional service URL in backend environment:

Terminal window
REGIONAL_SERVICE_URL=http://merq-indonesian-regional:8081

Indonesian administrative divisions follow a 4-level hierarchy:

Province (provinsi)
└── City (kota/kabupaten)
└── District (kecamatan)
└── Village (kelurahan/desa)

Example:

  • Province: DKI Jakarta (code: 31)
  • City: Jakarta Pusat (code: 3171)
  • District: Menteng (code: 317104)
  • Village: Menteng (code: 3171041001)

See Regional Service API for detailed endpoint documentation.

No new endpoints — enrichment fields are integrated into existing outlet endpoints.

EndpointChange
GET /office/v1/outletsResponse includes enrichment fields + nested objects (channel_group, channel, account, sub_area, store_province, etc.)
POST /office/v1/outletsAccepts all 12 new fields in request body
PUT /office/v1/outlets/:idAccepts all 12 new fields in request body
GET /app/v1/outletsMobile read-only access to enrichment fields
{
"name": "Carrefour Sudirman",
"address": "Jl. Jend. Sudirman No. 123",
"channel_group_id": 1,
"channel_id": 5,
"account_id": 10,
"sub_area_id": 3,
"timezone": "WIB",
"alt_geo_lat": -6.2088,
"alt_geo_lng": 106.8456,
"store_province_id": 31,
"store_city_id": 3171,
"store_district_id": 317104,
"store_village_id": 3171041001,
"remark": "High-traffic location, peak hours 12:00-14:00"
}
{
"success": true,
"message": "Outlet retrieved",
"code": "OUTLET_DETAIL",
"data": {
"id": 123,
"name": "Carrefour Sudirman",
"address": "Jl. Jend. Sudirman No. 123",
"channel_group": {
"id": 1,
"name": "Modern Trade"
},
"channel": {
"id": 5,
"name": "Supermarket"
},
"account": {
"id": 10,
"name": "Carrefour"
},
"sub_area": {
"id": 3,
"name": "Jakarta Central"
},
"timezone": "WIB",
"alt_geo_lat": -6.2088,
"alt_geo_lng": 106.8456,
"store_province": {
"id": 31,
"name": "DKI Jakarta",
"code": "31"
},
"store_city": {
"id": 3171,
"name": "Jakarta Pusat",
"code": "3171"
},
"store_district": {
"id": 317104,
"name": "Menteng",
"code": "317104"
},
"store_village": {
"id": 3171041001,
"name": "Menteng",
"code": "3171041001"
},
"remark": "High-traffic location, peak hours 12:00-14:00",
"created_at": "2026-03-04T12:00:00Z",
"updated_at": "2026-03-05T08:30:00Z"
}
}

List endpoint accepts additional filter params:

ParameterTypeDescription
channel_groupintegerFilter by channel group ID
channelintegerFilter by channel ID
accountintegerFilter by account ID
sub_areaintegerFilter by sub area ID
store_province_idintegerFilter by store province ID
store_city_idintegerFilter by store city ID

Example Query:

Terminal window
GET /office/v1/outlets?channel_group=1&store_province_id=31&page=1&limit=20

The migration 20260304120000_outlet_enrichment.sql adds:

New Tables:

  • channel_groups — Top-level classification
  • channels — Mid-level classification (FK to channel_groups)
  • accounts — Leaf-level classification (FK to channels)
  • sub_areas — Operational grouping

Outlet Table Changes:

  • 12 new columns (see table above)
  • Indexes on all FK columns for query performance
  • Unique indexes on (name, workspace_id) for each master table

Indexes Created:

CREATE INDEX idx_outlets_channel_group_id ON outlets (channel_group_id);
CREATE INDEX idx_outlets_channel_id ON outlets (channel_id);
CREATE INDEX idx_outlets_account_id ON outlets (account_id);
CREATE INDEX idx_outlets_sub_area_id ON outlets (sub_area_id);
CREATE INDEX idx_outlets_store_province_id ON outlets (store_province_id);
CREATE INDEX idx_outlets_store_city_id ON outlets (store_city_id);

To run the migration:

Terminal window
go run cmd/migrate/main.go

Four new workspace-scoped master entities were created:

EntityEndpointPermission Key
ChannelGroup/office/v1/outlets/channel-groupschannel_group.manage
Channel/office/v1/outlets/channelschannel.manage
Account/office/v1/outlets/accountsaccount.manage
SubArea/office/v1/outlets/sub-areassub_area.manage

Each entity supports full CRUD operations:

  • List: GET /office/v1/outlets/{entity} (paginated, workspace-scoped)
  • Detail: GET /office/v1/outlets/{entity}/:id
  • Create: POST /office/v1/outlets/{entity} (requires full access permission)
  • Update: PUT /office/v1/outlets/{entity}/:id (requires full access permission)
  • Delete: DELETE /office/v1/outlets/{entity}/:id (requires full access permission)

Hierarchy integrity is enforced:

  • Cannot delete a ChannelGroup if it has associated Channels
  • Cannot delete a Channel if it has associated Accounts
  • Attempt returns 409 Conflict error

See Master Data CRUD for detailed documentation.

All 4 master tables (channel_groups, channels, accounts, sub_areas) are workspace-scoped:

workspace_id BIGINT NOT NULL

Implications:

  • workspace_id extracted from auth context on every request
  • All queries automatically filtered by workspace
  • Master data isolated per tenant
  • Cross-workspace references impossible

Example Service Layer:

func (s *channelService) workspaceID(ctx context.Context) (uint, error) {
auth, err := utils.GetAuthContextValue(ctx)
if err != nil || auth == nil {
return 0, customerror.NewUnauthorized("unauthorized", err)
}
return auth.UserWorkspaceID, nil
}

The timezone field accepts one of three Indonesian timezone values:

ValueUTC OffsetRegions
WIBUTC+7Sumatra, Java, West/Central Kalimantan
WITAUTC+8Bali, Nusa Tenggara, South/East Kalimantan, Sulawesi
WITUTC+9Maluku, Papua

Default: WIB (most common for Java-based operations)

The alt_geo_lat and alt_geo_lng fields provide fallback coordinates when primary GPS (geo_lat/geo_lng) is unavailable or inaccurate.

Use Cases:

  • Indoor venues with poor GPS signal (malls, airports)
  • Multi-building complexes (use main entrance coordinates)
  • Manual coordinate entry for legacy outlets

Defaults: 0, 0 (indicates not set)