# GSD: Innomaint API Sync — Fetch & Store Asset Detail
## IC3 v1 | Phase: P1 Data & Field Intelligence

---

## CONTEXT

We have 2957 assets imported into `ic3_asset_master` from Excel.
Each row has `cmms_asset_id` = the Innomaint Asset ID string (e.g. `Delhi Cantt Naraina_BP_01`).

Now we need to call the Innomaint API for each asset to enrich the DB with:
- `traceability_id`, `barcode`, `criticality` (API value overrides Excel)
- `building_id`, `floor_id`, `department_id` (not in Excel, only from API)
- `ticketcount`, `schedulecount`, `capacity_rating`, `in_charge_person`
- `ic3_asset_performance` table — mttr, mtbf, availability
- `ic3_workorder_summary` table — upto + last_year counts
- `ic3_building`, `ic3_floor_master`, `ic3_department_master` — upsert from API data

---

## INNOMAINT API DETAILS

**Endpoint:** `POST https://app.innomaint.com/api/public/api/restapicontroller/assetmanagement/getAssetIDDetailByAssetID`

**Header:** `Authorization: Bearer 94yUVWfqdxgCrHouB96eeAcSnlW2YejlVopBHFI4lRoIBxQVmnInw0ZiLC6b`

**Request Body:**
```json
{ "serialnumber": "Delhi Cantt Naraina_BP_01" }
```

**Response shape (abbreviated):**
```json
{
  "status": true,
  "response_code": 200,
  "response": {
    "data": {
      "id": 577833,
      "traceid": 577833,
      "serialnumber": "Delhi Cantt Naraina_BP_01",
      "barcode": "MOD135692/SER577833",
      "criticality": "High",
      "capacity_rating": "90KW",
      "customer_id": 24463,
      "customer_name": "DJB Khyala",
      "location_id": 34801,
      "location_name": "Naraina D.Cantt UGR_Zone",
      "building_id": 16420,
      "building_name": "Naraina D.Cantt UGR_Zone",
      "floor_id": 12030,
      "floor_name": "UGR",
      "department_id": 12250,
      "department_name": "Working",
      "asset_floor_id": 20025,
      "asset_building_id": 21852,
      "asset_department_id": 27422,
      "installation_date": null,
      "ticketcount": 5,
      "schedulecount": 44,
      "schedule_configured": 1,
      "schedule_initiate": 1,
      "in_charge_person": "JWIL",
      "iot_device_mapped": 0,
      "status": 1,
      "asset_live_status": 1,
      "traceability_updated_on": "2026-03-19 04:21:46+00",
      "fk_customers_equipment_mapping_id": 550822,
      "customers_locations_serialnumber_mapping_id": 505836,
      "customer_traceability_id": 550822,
      "customer_model_id": 225345,
      "location_traceability_id": 505836,
      "location_model_id": 252933
    },
    "performance": {
      "equipments_name": "LT-Panel",
      "equipment_model_name": "NA",
      "criticality_type_label": "Non-Critical",
      "total_ticket": 2,
      "mttr": "00:08:30",
      "mtbf": "8526:06:43",
      "availability": "100",
      "total_ticket_breakdown_hrs": "00:17:00",
      "total_schedule_breakdown_hrs": "0",
      "asset_created_date": "09/07/2024 16:59",
      "total_actual_hrs": "17052:30:27"
    },
    "workorder_transactional": {
      "upto": {
        "work_estimate_count": "2",
        "work_estimate_cost": "0.00",
        "schedule_count": "0",
        "schedule_cost": "0.00",
        "ticket_count": "2",
        "ticket_cost": "0.00"
      },
      "last_year": {
        "work_estimate_count": "0",
        "work_estimate_cost": "0.00",
        "schedule_count": "0",
        "schedule_cost": "0.00",
        "ticket_count": "0",
        "ticket_cost": "0.00"
      }
    }
  }
}
```

---

## FILES TO MODIFY

### 1. `cmms_import.go` — ADD these new functions

#### A. Structs for API response

```go
// InnomaintAPIResponse — top level
type InnomaintAPIResponse struct {
    Status       bool                   `json:"status"`
    ResponseCode int                    `json:"response_code"`
    Response     InnomaintResponseBody  `json:"response"`
}

type InnomaintResponseBody struct {
    Data                  InnomaintAssetData        `json:"data"`
    Performance           InnomaintPerformance      `json:"performance"`
    WorkorderTransactional InnomaintWorkorder        `json:"workorder_transactional"`
}

type InnomaintAssetData struct {
    ID                                    int     `json:"id"`
    TraceID                               int     `json:"traceid"`
    Serialnumber                          string  `json:"serialnumber"`
    Barcode                               string  `json:"barcode"`
    Criticality                           string  `json:"criticality"`
    CapacityRating                        string  `json:"capacity_rating"`
    CustomerID                            int     `json:"customer_id"`
    CustomerName                          string  `json:"customer_name"`
    LocationID                            int     `json:"location_id"`
    LocationName                          string  `json:"location_name"`
    BuildingID                            int     `json:"building_id"`
    BuildingName                          string  `json:"building_name"`
    FloorID                               int     `json:"floor_id"`
    FloorName                             string  `json:"floor_name"`
    DepartmentID                          int     `json:"department_id"`
    DepartmentName                        string  `json:"department_name"`
    AssetFloorID                          int     `json:"asset_floor_id"`
    AssetBuildingID                       int     `json:"asset_building_id"`
    AssetDepartmentID                     int     `json:"asset_department_id"`
    FkCustomersEquipmentMappingID         int     `json:"fk_customers_equipment_mapping_id"`
    CustomersLocationsSerialMappingID     int     `json:"customers_locations_serialnumber_mapping_id"`
    CustomerTraceabilityID                int     `json:"customer_traceability_id"`
    CustomerModelID                       int     `json:"customer_model_id"`
    LocationTraceabilityID                int     `json:"location_traceability_id"`
    LocationModelID                       int     `json:"location_model_id"`
    InChargePerson                        string  `json:"in_charge_person"`
    Ticketcount                           int     `json:"ticketcount"`
    Schedulecount                         int     `json:"schedulecount"`
    ScheduleConfigured                    int     `json:"schedule_configured"`
    ScheduleInitiate                      int     `json:"schedule_initiate"`
    IotDeviceMapped                       int     `json:"iot_device_mapped"`
    Status                                int     `json:"status"`
    AssetLiveStatus                       int     `json:"asset_live_status"`
    MappingStatus                         int     `json:"mapping_status"`
    TraceabilityStatus                    int     `json:"traceability_status"`
    TraceabilityUpdatedOn                 string  `json:"traceability_updated_on"`
    InstallationDate                      *string `json:"installation_date"`
    YearOfManufacturing                   *int    `json:"year_of_manufacturing"`
    IsLoaner                              int     `json:"is_loaner"`
    IsBleTracking                         int     `json:"is_ble_tracking"`
    IsRfidTracking                        int     `json:"is_rfid_tracking"`
    IsQrTracking                          int     `json:"is_qr_tracking"`
    IsTrackingEnabled                     int     `json:"is_tracking_enabled"`
}

type InnomaintPerformance struct {
    EquipmentsName              string `json:"equipments_name"`
    EquipmentModelName          string `json:"equipment_model_name"`
    CriticalityTypeLabel        string `json:"criticality_type_label"`
    TotalTicket                 int    `json:"total_ticket"`
    Mttr                        string `json:"mttr"`
    Mtbf                        string `json:"mtbf"`
    Availability                string `json:"availability"`
    TotalTicketBreakdownHrs     string `json:"total_ticket_breakdown_hrs"`
    TotalScheduleBreakdownHrs   string `json:"total_schedule_breakdown_hrs"`
    AssetCreatedDate            string `json:"asset_created_date"`
    TotalActualHrs              string `json:"total_actual_hrs"`
}

type InnomaintWOPeriod struct {
    WorkEstimateCount string `json:"work_estimate_count"`
    WorkEstimateCost  string `json:"work_estimate_cost"`
    ScheduleCount     string `json:"schedule_count"`
    ScheduleCost      string `json:"schedule_cost"`
    TicketCount       string `json:"ticket_count"`
    TicketCost        string `json:"ticket_cost"`
}

type InnomaintWorkorder struct {
    Upto     InnomaintWOPeriod `json:"upto"`
    LastYear InnomaintWOPeriod `json:"last_year"`
}

// SyncResult — returned by the sync job
type SyncResult struct {
    Total    int      `json:"total"`
    Success  int      `json:"success"`
    Failed   int      `json:"failed"`
    Skipped  int      `json:"skipped"`
    Errors   []string `json:"errors,omitempty"`
    Duration string   `json:"duration_ms"`
}
```

#### B. API fetch function

```go
const (
    innomaintAPIURL   = "https://app.innomaint.com/api/public/api/restapicontroller/assetmanagement/getAssetIDDetailByAssetID"
    innomaintAPIToken = "94yUVWfqdxgCrHouB96eeAcSnlW2YejlVopBHFI4lRoIBxQVmnInw0ZiLC6b"
    innomaintRateMS   = 200 // ms between requests — be polite to their API
)

// FetchInnomaintAssetDetail calls the Innomaint API for one asset
func FetchInnomaintAssetDetail(ctx context.Context, client *http.Client, serialnumber string) (*InnomaintAPIResponse, error) {
    body, _ := json.Marshal(map[string]string{"serialnumber": serialnumber})
    req, err := http.NewRequestWithContext(ctx, "POST", innomaintAPIURL, bytes.NewReader(body))
    if err != nil {
        return nil, err
    }
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer "+innomaintAPIToken)

    resp, err := client.Do(req)
    if err != nil {
        return nil, fmt.Errorf("http: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != 200 {
        return nil, fmt.Errorf("API status %d", resp.StatusCode)
    }

    var result InnomaintAPIResponse
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return nil, fmt.Errorf("decode: %w", err)
    }
    if !result.Status || result.ResponseCode != 200 {
        return nil, fmt.Errorf("API returned status=false")
    }
    return &result, nil
}
```

#### C. DB enrichment function

```go
// EnrichAssetFromAPI updates ic3_asset_master + related tables from API response
func (db *DB) EnrichAssetFromAPI(ctx context.Context, tx pgx.Tx, apiResp *InnomaintAPIResponse) error {
    d := apiResp.Response.Data
    p := apiResp.Response.Performance
    wo := apiResp.Response.WorkorderTransactional

    // 1. Upsert ic3_building
    if d.BuildingID > 0 {
        _, err := tx.Exec(ctx, `
            INSERT INTO ic3_building (building_id, customer_id, building_name, created_at, updated_at)
            VALUES ($1, $2, $3, NOW(), NOW())
            ON CONFLICT (building_id) DO UPDATE SET
                building_name = EXCLUDED.building_name,
                updated_at    = NOW()`,
            d.BuildingID, nullInt(d.CustomerID), d.BuildingName,
        )
        if err != nil {
            return fmt.Errorf("upsert building: %w", err)
        }
    }

    // 2. Upsert ic3_floor_master
    if d.FloorID > 0 {
        _, err := tx.Exec(ctx, `
            INSERT INTO ic3_floor_master (floor_id, floor_name, location_id, asset_floor_id, created_at, updated_at)
            VALUES ($1, $2, $3, $4, NOW(), NOW())
            ON CONFLICT (floor_id) DO UPDATE SET
                floor_name    = EXCLUDED.floor_name,
                updated_at    = NOW()`,
            d.FloorID, d.FloorName, nullInt(d.LocationID), nullInt(d.AssetFloorID),
        )
        if err != nil {
            return fmt.Errorf("upsert floor: %w", err)
        }
    }

    // 3. Upsert ic3_department_master
    if d.DepartmentID > 0 {
        _, err := tx.Exec(ctx, `
            INSERT INTO ic3_department_master (department_id, department_name, floor_id, location_id, asset_department_id, created_at, updated_at)
            VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
            ON CONFLICT (department_id) DO UPDATE SET
                department_name    = EXCLUDED.department_name,
                updated_at         = NOW()`,
            d.DepartmentID, d.DepartmentName,
            nullInt(d.FloorID), nullInt(d.LocationID), nullInt(d.AssetDepartmentID),
        )
        if err != nil {
            return fmt.Errorf("upsert department: %w", err)
        }
    }

    // 4. Update ic3_asset_master — enrich with API data
    _, err := tx.Exec(ctx, `
        UPDATE ic3_asset_master SET
            traceability_id                            = $2,
            barcode                                    = $3,
            criticality                                = $4,
            capacity_rating                            = $5,
            building_id                                = $6,
            floor_id                                   = $7,
            department_id                              = $8,
            asset_building_id                          = $9,
            asset_floor_id                             = $10,
            asset_department_id                        = $11,
            fk_customers_equipment_mapping_id          = $12,
            customers_locations_serialnumber_mapping_id= $13,
            customer_traceability_id                   = $14,
            customer_model_id                          = $15,
            location_traceability_id                   = $16,
            location_model_id                          = $17,
            in_charge_person                           = $18,
            ticketcount                                = $19,
            schedulecount                              = $20,
            schedule_configured                        = $21,
            schedule_initiate                          = $22,
            iot_device_mapped                          = $23,
            status                                     = $24,
            asset_live_status                          = $25,
            mapping_status                             = $26,
            traceability_status                        = $27,
            is_loaner                                  = $28,
            is_ble_tracking                            = $29,
            is_rfid_tracking                           = $30,
            is_qr_tracking                             = $31,
            is_tracking_enabled                        = $32,
            last_synced_at                             = NOW(),
            sync_version                               = COALESCE(sync_version, 0) + 1,
            updated_at                                 = NOW()
        WHERE cmms_asset_id = $1`,
        d.Serialnumber,           // $1 WHERE key
        d.TraceID,                // $2
        nullStr(d.Barcode),       // $3
        nullStr(d.Criticality),   // $4
        nullStr(d.CapacityRating),// $5
        nullInt(d.BuildingID),    // $6
        nullInt(d.FloorID),       // $7
        nullInt(d.DepartmentID),  // $8
        nullInt(d.AssetBuildingID),   // $9
        nullInt(d.AssetFloorID),      // $10
        nullInt(d.AssetDepartmentID), // $11
        nullInt(d.FkCustomersEquipmentMappingID),       // $12
        nullInt(d.CustomersLocationsSerialMappingID),   // $13
        nullInt(d.CustomerTraceabilityID),              // $14
        nullInt(d.CustomerModelID),                     // $15
        nullInt(d.LocationTraceabilityID),              // $16
        nullInt(d.LocationModelID),                     // $17
        nullStr(d.InChargePerson),  // $18
        d.Ticketcount,              // $19
        d.Schedulecount,            // $20
        d.ScheduleConfigured,       // $21
        d.ScheduleInitiate,         // $22
        d.IotDeviceMapped,          // $23
        d.Status,                   // $24
        d.AssetLiveStatus,          // $25
        d.MappingStatus,            // $26
        d.TraceabilityStatus,       // $27
        d.IsLoaner,                 // $28
        d.IsBleTracking,            // $29
        d.IsRfidTracking,           // $30
        d.IsQrTracking,             // $31
        d.IsTrackingEnabled,        // $32
    )
    if err != nil {
        return fmt.Errorf("update asset_master: %w", err)
    }

    // Get asset_id for child table inserts
    var assetID int
    err = tx.QueryRow(ctx, `SELECT asset_id FROM ic3_asset_master WHERE cmms_asset_id = $1`, d.Serialnumber).Scan(&assetID)
    if err != nil {
        return fmt.Errorf("get asset_id: %w", err)
    }

    // 5. Upsert ic3_asset_performance
    _, err = tx.Exec(ctx, `
        INSERT INTO ic3_asset_performance (
            asset_id, serialnumber, equipments_name, equipment_model_name,
            criticality_type_label, total_ticket, mttr, mtbf, availability,
            total_ticket_breakdown_hrs, total_schedule_breakdown_hrs,
            asset_created_date, total_actual_hrs, recorded_at
        ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,NOW())
        ON CONFLICT (asset_id) DO UPDATE SET
            total_ticket                = EXCLUDED.total_ticket,
            mttr                        = EXCLUDED.mttr,
            mtbf                        = EXCLUDED.mtbf,
            availability                = EXCLUDED.availability,
            total_ticket_breakdown_hrs  = EXCLUDED.total_ticket_breakdown_hrs,
            total_schedule_breakdown_hrs= EXCLUDED.total_schedule_breakdown_hrs,
            total_actual_hrs            = EXCLUDED.total_actual_hrs,
            recorded_at                 = NOW()`,
        assetID, d.Serialnumber,
        p.EquipmentsName, p.EquipmentModelName, p.CriticalityTypeLabel,
        p.TotalTicket, p.Mttr, p.Mtbf, p.Availability,
        p.TotalTicketBreakdownHrs, p.TotalScheduleBreakdownHrs,
        p.AssetCreatedDate, p.TotalActualHrs,
    )
    if err != nil {
        return fmt.Errorf("upsert performance: %w", err)
    }

    // 6. Upsert ic3_workorder_summary — two rows (upto + last_year)
    for _, period := range []struct {
        pType string
        wop   InnomaintWOPeriod
    }{
        {"upto", wo.Upto},
        {"last_year", wo.LastYear},
    } {
        weCount, _ := strconv.Atoi(period.wop.WorkEstimateCount)
        weCost, _  := strconv.ParseFloat(period.wop.WorkEstimateCost, 64)
        scCount, _ := strconv.Atoi(period.wop.ScheduleCount)
        scCost, _  := strconv.ParseFloat(period.wop.ScheduleCost, 64)
        tkCount, _ := strconv.Atoi(period.wop.TicketCount)
        tkCost, _  := strconv.ParseFloat(period.wop.TicketCost, 64)

        _, err = tx.Exec(ctx, `
            INSERT INTO ic3_workorder_summary (
                asset_id, period_type,
                work_estimate_count, work_estimate_cost,
                schedule_count, schedule_cost,
                ticket_count, ticket_cost, updated_at
            ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW())
            ON CONFLICT (asset_id, period_type) DO UPDATE SET
                work_estimate_count = EXCLUDED.work_estimate_count,
                work_estimate_cost  = EXCLUDED.work_estimate_cost,
                schedule_count      = EXCLUDED.schedule_count,
                schedule_cost       = EXCLUDED.schedule_cost,
                ticket_count        = EXCLUDED.ticket_count,
                ticket_cost         = EXCLUDED.ticket_cost,
                updated_at          = NOW()`,
            assetID, period.pType,
            weCount, weCost, scCount, scCost, tkCount, tkCost,
        )
        if err != nil {
            return fmt.Errorf("upsert workorder %s: %w", period.pType, err)
        }
    }

    return nil
}
```

#### D. Batch sync orchestrator

```go
// SyncAllAssetsFromInnomaint fetches all assets from Innomaint API and enriches DB
// batchSize: how many to sync per call (0 = all)
// offsetStart: skip first N assets (for resume/pagination)
func (db *DB) SyncAllAssetsFromInnomaint(ctx context.Context, batchSize, offsetStart int) (SyncResult, error) {
    start := time.Now()
    result := SyncResult{}

    // Fetch all cmms_asset_ids from DB
    rows, err := db.pool.Query(ctx, `
        SELECT cmms_asset_id FROM ic3_asset_master
        WHERE cmms_asset_id IS NOT NULL
        ORDER BY report_seq_no ASC NULLS LAST
        LIMIT $1 OFFSET $2`,
        func() int { if batchSize <= 0 { return 9999 }; return batchSize }(),
        offsetStart,
    )
    if err != nil {
        return result, fmt.Errorf("fetch cmms_asset_ids: %w", err)
    }
    var assetIDs []string
    for rows.Next() {
        var id string
        rows.Scan(&id)
        assetIDs = append(assetIDs, id)
    }
    rows.Close()

    result.Total = len(assetIDs)
    client := &http.Client{Timeout: 15 * time.Second}

    for i, cmmsID := range assetIDs {
        // Rate limit
        if i > 0 {
            time.Sleep(innomaintRateMS * time.Millisecond)
        }

        apiResp, err := FetchInnomaintAssetDetail(ctx, client, cmmsID)
        if err != nil {
            result.Failed++
            result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", cmmsID, err))
            // Log to ic3_sync_log
            db.pool.Exec(ctx, `
                INSERT INTO ic3_sync_log (serialnumber, sync_type, status, error_message, synced_at)
                VALUES ($1, 'api_sync', 'error', $2, NOW())`,
                cmmsID, err.Error(),
            )
            continue
        }

        // Each asset gets its own transaction — don't rollback all on one failure
        tx, err := db.pool.Begin(ctx)
        if err != nil {
            result.Failed++
            continue
        }

        if err := db.EnrichAssetFromAPI(ctx, tx, apiResp); err != nil {
            tx.Rollback(ctx)
            result.Failed++
            result.Errors = append(result.Errors, fmt.Sprintf("%s: enrich: %v", cmmsID, err))
            db.pool.Exec(ctx, `
                INSERT INTO ic3_sync_log (serialnumber, sync_type, status, error_message, synced_at)
                VALUES ($1, 'api_sync', 'error', $2, NOW())`,
                cmmsID, err.Error(),
            )
            continue
        }

        tx.Commit(ctx)
        result.Success++

        // Log success
        db.pool.Exec(ctx, `
            INSERT INTO ic3_sync_log (serialnumber, sync_type, status, synced_at)
            VALUES ($1, 'api_sync', 'success', NOW())`,
            cmmsID,
        )
    }

    // Log batch to cmms_sync_log
    db.pool.Exec(ctx, `
        INSERT INTO cmms_sync_log (sync_type, status, assets_synced, duration_ms)
        VALUES ('api_sync', 'complete', $1, $2)`,
        result.Success, int(time.Since(start).Milliseconds()),
    )

    result.Duration = strconv.FormatInt(time.Since(start).Milliseconds(), 10)
    return result, nil
}
```

---

### 2. `handlers.go` — ADD these two handlers

#### A. Full sync handler — POST /api/admin/cmms/sync

```go
// POST /api/admin/cmms/sync?batch=500&offset=0
// Triggers Innomaint API sync for all (or batched) assets
func syncInnomaintAssetsHandler(db *DB) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")

        q := r.URL.Query()
        batch, _  := strconv.Atoi(q.Get("batch"))
        offset, _ := strconv.Atoi(q.Get("offset"))

        // Default batch = 100 to be safe on first run
        if batch <= 0 {
            batch = 100
        }

        // Run with generous timeout — 100 assets * 200ms = 20s minimum
        timeout := time.Duration(batch+10) * 300 * time.Millisecond
        if timeout < 30*time.Second {
            timeout = 30 * time.Second
        }
        ctx, cancel := context.WithTimeout(r.Context(), timeout)
        defer cancel()

        result, err := db.SyncAllAssetsFromInnomaint(ctx, batch, offset)
        if err != nil {
            w.WriteHeader(500)
            json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
            return
        }

        w.WriteHeader(200)
        json.NewEncoder(w).Encode(result)
    }
}
```

#### B. Single asset sync — POST /api/admin/cmms/sync/{id}

```go
// POST /api/admin/cmms/sync/{id}
// Sync one asset by cmms_asset_id — useful for testing and on-demand refresh
func syncSingleAssetHandler(db *DB) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")

        cmmsID := r.PathValue("id")
        if cmmsID == "" {
            w.WriteHeader(400)
            json.NewEncoder(w).Encode(map[string]string{"error": "cmms_asset_id required"})
            return
        }

        ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
        defer cancel()

        client := &http.Client{Timeout: 10 * time.Second}
        apiResp, err := FetchInnomaintAssetDetail(ctx, client, cmmsID)
        if err != nil {
            w.WriteHeader(502)
            json.NewEncoder(w).Encode(map[string]string{"error": "innomaint api: " + err.Error()})
            return
        }

        tx, err := db.pool.Begin(ctx)
        if err != nil {
            w.WriteHeader(500)
            json.NewEncoder(w).Encode(map[string]string{"error": "tx begin: " + err.Error()})
            return
        }
        defer tx.Rollback(ctx)

        if err := db.EnrichAssetFromAPI(ctx, tx, apiResp); err != nil {
            w.WriteHeader(500)
            json.NewEncoder(w).Encode(map[string]string{"error": "enrich: " + err.Error()})
            return
        }

        if err := tx.Commit(ctx); err != nil {
            w.WriteHeader(500)
            json.NewEncoder(w).Encode(map[string]string{"error": "commit: " + err.Error()})
            return
        }

        json.NewEncoder(w).Encode(map[string]any{
            "status":       "synced",
            "cmms_asset_id": cmmsID,
            "traceability_id": apiResp.Response.Data.TraceID,
        })
    }
}
```

---

### 3. `main.go` — REGISTER the two new routes

Find your route registration block and add:

```go
// Innomaint API sync
mux.HandleFunc("POST /api/admin/cmms/sync",       syncInnomaintAssetsHandler(db))
mux.HandleFunc("POST /api/admin/cmms/sync/{id}",  syncSingleAssetHandler(db))
```

---

### 4. SQL — ADD missing unique constraints on child tables (run once)

```sql
-- ic3_asset_performance needs unique on asset_id for ON CONFLICT
ALTER TABLE ic3_asset_performance
    ADD CONSTRAINT uq_performance_asset_id UNIQUE (asset_id);

-- ic3_workorder_summary needs unique on (asset_id, period_type)
ALTER TABLE ic3_workorder_summary
    ADD CONSTRAINT uq_workorder_asset_period UNIQUE (asset_id, period_type);

-- ic3_building needs unique on building_id (it's the PK, should exist)
-- ic3_floor_master needs unique on floor_id (PK, should exist)
-- ic3_department_master needs unique on department_id (PK, should exist)
```

---

### 5. IMPORTS to add in `cmms_import.go`

```go
import (
    "bytes"         // for bytes.NewReader in API call
    "net/http"      // for http.Client
    "strconv"       // already there, for Atoi/ParseFloat in workorder
    // all others already present
)
```

---

## TESTING SEQUENCE

**Step 1 — Test single asset first:**
```bash
curl -X POST http://localhost:9090/api/admin/cmms/sync/Delhi%20Cantt%20Naraina_BP_01
```
Expected: `{"status":"synced","cmms_asset_id":"Delhi Cantt Naraina_BP_01","traceability_id":577833}`

**Step 2 — Check DB:**
```sql
SELECT asset_id, cmms_asset_id, traceability_id, criticality, ticketcount,
       building_id, floor_id, department_id, last_synced_at
FROM ic3_asset_master
WHERE cmms_asset_id = 'Delhi Cantt Naraina_BP_01';

SELECT * FROM ic3_asset_performance WHERE asset_id = (
    SELECT asset_id FROM ic3_asset_master WHERE cmms_asset_id = 'Delhi Cantt Naraina_BP_01'
);
```

**Step 3 — Batch sync (100 at a time):**
```bash
# First 100
curl -X POST "http://localhost:9090/api/admin/cmms/sync?batch=100&offset=0"
# Next 100
curl -X POST "http://localhost:9090/api/admin/cmms/sync?batch=100&offset=100"
# ... repeat until Total = Success + Failed
```

**Step 4 — Check sync log:**
```sql
SELECT status, COUNT(*) FROM ic3_sync_log
WHERE sync_type = 'api_sync'
GROUP BY status;
```

---

## WHAT CHANGES IN EXISTING CODE

| File | What changes |
|---|---|
| `cmms_import.go` | ADD structs + 3 new functions — nothing removed |
| `handlers.go` | ADD 2 new handler functions — nothing removed |
| `main.go` | ADD 2 route registrations |
| DB | ADD 2 UNIQUE constraints (performance + workorder) |

**No existing functions are modified.** Only additions.
