# DOS DB — Mobile App API Reference

This document describes the JSON API exposed by the DOS DB Laravel backend for the mobile/desktop
client app. It is written so that a developer **or an AI coding assistant** can implement a client
without reading the server source.

Server implementation: `app/Http/Controllers/ApiController.php` (section "Mobile app APIs"),
routes in `routes/web.php`.

---

## 1. Basics

| Item | Value |
|---|---|
| Base URL | `https://<your-server>` (local dev: `http://127.0.0.1:8000`) |
| Prefix | `/api/app` |
| Method | All endpoints are `GET` |
| Format | JSON, UTF‑8, Unicode is **not** escaped (Kurdish/Arabic text is returned as-is) |
| Auth | API key — see below |
| Timezone | All dates are `Asia/Baghdad` local time, format `YYYY-MM-DD HH:MM:SS` |
| Price unit | **All prices are per dozen (12 pcs)**. Fields ending in `_pcs` are already divided by 12. |
| Quantities | `qty` values are always in **pieces (pcs)**. `cartons = qty / pak_qty`. |
| Currency | Iraqi Dinar (IQD), integers |

### 1.1 Authentication

Every request must carry the API key, either as a query parameter or a header:

```
GET /api/app/products/search?q=...&key=YOUR_APP_API_KEY
```
or
```
X-Api-Key: YOUR_APP_API_KEY
```

The key is the value of `APP_API_KEY` in the server `.env`.

### 1.2 Error responses

| HTTP | Body | Meaning |
|---|---|---|
| 401 | `{"error":"invalid key"}` | Missing/wrong key |
| 404 | `{"error":"product not found"}` / `{"error":"shop not found"}` | Bad id |
| 500 | `{"error":"APP_API_KEY is not configured on the server"}` | Server misconfiguration |

Success responses always have a top-level `data` key (and `meta` where paginated).

### 1.3 URL encoding

Kurdish/Arabic search text **must be URL‑encoded** (`encodeURIComponent` / `Uri.encodeQueryComponent`).
Example: `q=سەلە قاپ` → `q=%D8%B3%DB%95%D9%84%DB%95%20%D9%82%D8%A7%D9%BE`.

---

## 2. Endpoints overview

| # | Endpoint | Purpose |
|---|---|---|
| 1 | `GET /api/app/products/search` | Search / list products (light payload) |
| 2 | `GET /api/app/products/{id}` | Full product detail: stock, purchases, sales, China shop, locations |
| 3 | `GET /api/app/products/{id}/prices` | Price history for charts + statistics + similar products |
| 4 | `GET /api/app/china/shops` | All China supplier shops with stats |
| 5 | `GET /api/app/china/shops/{id}` | One shop with every product's sold / remaining numbers |
| 6 | `GET /api/app/tracking` | My tracked containers (standalone list) with live sea-tracking status |
| 7 | `POST /api/app/tracking` | Add a container number to track (registers on ShipsGo) |
| 8 | `GET /api/app/tracking/{id}` | Full tracking detail: route, ETA, vessel, movement timeline |
| 9 | `POST /api/app/tracking/{id}` | Edit name / note / archive |
| 10 | `POST /api/app/tracking/{id}/delete` | Remove from the list |
| 11 | `GET /api/app/tracking/{id}/map` | GeoJSON route / vessel position for a map widget |
| 12 | `POST /api/app/tracking/import` | Import shipments that already exist in the ShipsGo account (free) |
| 13 | `GET /api/app/shipsgo/carriers` | Carrier list (SCAC codes) for the add form |

Typical app flow: **search (1) → tap product → detail (2) → "Prices" tab → (3)**;
**China tab → shops list (4) → tap shop → (5) → tap product → (2)**.

---

## 3. Shared object: `ProductBasic`

Returned by every endpoint that lists products. Endpoint 2 extends it (see §5).

```json
{
  "id": 10925,
  "name": "سەلەی قاپ تەخت مێرسە",
  "arabic": null,
  "code": "600-47",
  "barcode": null,
  "brand": "mersa",
  "supplier": null,
  "note": null,
  "stop": 0,
  "images": {
    "image":  "https://www.dos-shop.com/files/product_image/abc.jpg",
    "thumb":  "https://www.dos-shop.com/files/product_image/size_250/abc.jpg",
    "medium": "https://www.dos-shop.com/files/product_image/size_500/abc.jpg"
  },
  "prices": {
    "unit": "dozen",
    "p_price": 23000,
    "s_price": 24000,
    "w_price": 24000,
    "p_price_pcs": 1916.67,
    "s_price_pcs": 2000,
    "w_price_pcs": 2000,
    "margin": 1000,
    "margin_pct": 4.35
  },
  "stock": {
    "qty": 120,
    "pak_qty": 12,
    "cartons": 10,
    "min_qty": 0,
    "low_stock": false,
    "stock_value": 230000
  },
  "sales": {
    "sold_qty": 0,
    "sell_count": 0,
    "last_sold_at": null
  },
  "created_at": "2026-06-10 14:42:08",
  "updated_at": "2026-06-10 14:42:08"
}
```

| Field | Type | Description |
|---|---|---|
| `id` | int | Product id (use for endpoints 2 & 3) |
| `name` | string | Kurdish name |
| `arabic` | string\|null | Arabic name |
| `code` | string\|null | Product code (China shop prefix, e.g. `AH-12`) |
| `barcode` | string\|null | Barcode |
| `brand` | string\|null | Brand |
| `supplier` | string\|null | Free-text supplier/batch tag |
| `note` | string\|null | Product note (تێبینی) |
| `stop` | 0\|1 | 1 = discontinued (hidden from search unless `include_stopped=1`) |
| `images.image` | string\|null | Full-size image URL |
| `images.thumb` | string\|null | 250px thumbnail (use in lists) |
| `images.medium` | string\|null | 500px image (use in detail) |
| `prices.unit` | `"dozen"` | Constant — reminder that `*_price` are per 12 pcs |
| `prices.p_price` | number | Purchase (buy) price per dozen |
| `prices.s_price` | number | Selling price per dozen |
| `prices.w_price` | number | Wholesale price per dozen |
| `prices.*_pcs` | number | Same prices per single piece |
| `prices.margin` | number | `s_price - p_price` |
| `prices.margin_pct` | number\|null | Margin % over purchase price |
| `stock.qty` | number | Current quantity in **pcs** (بڕی ئێستا) |
| `stock.pak_qty` | int | Pieces per carton (بڕی ناو کارتۆن) |
| `stock.cartons` | number\|null | `qty / pak_qty`, null if `pak_qty` is 0 |
| `stock.min_qty` | int | Reorder threshold |
| `stock.low_stock` | bool | `min_qty > 0 && qty <= min_qty` |
| `stock.stock_value` | number | `qty * p_price / 12` (IQD value of stock at cost) |
| `sales.sold_qty` | number | Total pcs ever sold (deleted bills excluded) |
| `sales.sell_count` | int | Number of bill lines |
| `sales.last_sold_at` | datetime\|null | Last sale date |

**Image fallback:** when the product has no uploaded image, all three image URLs point to
`http://139.162.163.229/dos/uploads/china_2024/{code}.jpg` (may 404 — show a placeholder on error).
If `code` is also empty, the image fields are `null`.

---

## 4. `GET /api/app/products/search`

Search across **all** product fields. Each whitespace-separated word must match at least one of:
`name, arabic, code, barcode, brand, note, supplier` (LIKE %word%), or `id` if the word is numeric.

### Query parameters

| Param | Default | Description |
|---|---|---|
| `q` | `""` | Search text. Empty = list all |
| `page` | 1 | Page number |
| `per_page` | 30 | 1–100 |
| `sort` | `newest` | `newest` \| `name` \| `qty` \| `price` \| `sold` |
| `in_stock` | 0 | `1` = only `qty > 0` |
| `include_stopped` | 0 | `1` = include discontinued products |
| `brand` | — | Exact brand filter |
| `shop_id` | — | Only products of this China shop (see §7) |

### Response

```json
{
  "data": [ ProductBasic, ... ],
  "meta": {
    "q": "سەلە قاپ",
    "page": 1,
    "per_page": 30,
    "total": 31,
    "last_page": 2,
    "sort": "newest"
  }
}
```

### Example

```
GET /api/app/products/search?q=%DA%AF%D8%B3%DA%A9%DB%8C&in_stock=1&sort=sold&per_page=20&key=KEY
```

---

## 5. `GET /api/app/products/{id}`

Full detail. `data` is a `ProductBasic` **plus** the extra sections below.

```json
{
  "data": {
    "...ProductBasic fields...": "...",

    "stock": {
      "qty": 84, "pak_qty": 120, "cartons": 0.7, "min_qty": 0, "low_stock": false, "stock_value": 33950,
      "old_qty": 0,
      "qty_1": 0, "qty_2": 0, "qty_3": 0, "qty_4": 0, "qty_5": 0,
      "cbm": 0.2,
      "locations": [
        { "id": 3, "location_code": "F4Z8", "floor": 4, "zone": 8, "note": null, "created_at": "2026-02-12 10:00:00" }
      ]
    },

    "purchases": {
      "summary": {
        "count": 14,
        "total_qty": 34002,
        "total_cartons": 283.35,
        "total_cost": 13039250,
        "avg_p_price": 4582.14,
        "min_p_price": 4500,
        "max_p_price": 4700,
        "first_at": "2024-05-21 16:10:39",
        "last_at": "2026-03-08 18:14:29"
      },
      "items": [
        {
          "id": 20042,
          "p_price": 4700,
          "w_price": 5000,
          "s_price": 5250,
          "product_id": 716,
          "qty": 1914,
          "supplier": "hersh",
          "created_at": "2026-03-08 18:14:29",
          "updated_at": "2026-03-08 18:14:29",
          "note": null,
          "container_id": 12,
          "inventory_id": 0,

          "date": "2026-03-08 18:14:29",
          "cartons": 15.95,
          "prices": { "unit": "dozen", "p_price": 4700, "s_price": 5250, "w_price": 5000,
                      "p_price_pcs": 391.67, "s_price_pcs": 437.5, "w_price_pcs": 416.67,
                      "margin": 550, "margin_pct": 11.7 },
          "total_cost": 749650,
          "container": {
            "id": 12, "name": "Container 2026-3", "code": "C-26-3", "type": "china",
            "date_load": "2026-01-10 00:00:00", "date_down": "2026-03-05 00:00:00", "note": null
          }
        }
      ]
    },

    "sales": {
      "sold_qty": 76494,
      "sell_count": 1250,
      "revenue": 30951400,
      "cost": 28804160,
      "profit": 2147240,
      "first_sold_at": "2022-06-30 09:35:45",
      "last_sold_at": "2026-06-10 15:56:30",
      "sold_cartons": 637.45,
      "avg_monthly_qty": 866,
      "stock_cover_months": 0.1,
      "by_month": [
        { "month": "2025-11", "sold_qty": 1200, "sell_count": 21, "revenue": 525000 },
        { "month": "2025-12", "sold_qty": 0,    "sell_count": 0,  "revenue": 0 }
      ],
      "recent": [
        {
          "sell_id": 27253,
          "date": "2026-06-10 15:56:30",
          "type": "pcs",
          "qty": 12,
          "qty_pcs": 12,
          "s_price": 5250,
          "total": 5250,
          "note": "",
          "customer": { "id": 55, "name": "کاک جیهاد", "phone": "0750..." },
          "status": "new"
        }
      ]
    },

    "china": {
      "shop": { "id": 12, "name": "پلاستیک وردەکان", "code": "AH-", "code2": "18-",
                "shop_number": "35194", "wechat": null, "note": null,
                "created_at": "...", "updated_at": "..." },
      "buy": 0,
      "buyed": 0,
      "note": null
    },

    "container_id": null,
    "edit_at": "2026-03-08 18:14:29",
    "history_count": 34,
    "analysis": { "...see §8.1 — same object as in the China shop detail...": "..." }
  }
}
```

### Field notes

**stock (extra)**

| Field | Description |
|---|---|
| `old_qty` | Quantity before last inventory count |
| `qty_1..qty_5` | Per-store/legacy quantity slots |
| `cbm` | Cubic meters per carton (قەبارە) |
| `locations[]` | Warehouse locations (`location_code` = `F{floor}Z{zone}`) |

**purchases** — every buying record for this product (کڕینەکان), newest first.
Each item contains **all raw columns of the `products_purchases` table** followed by computed extras.

| Field | Description |
|---|---|
| `summary.count` | Number of purchases |
| `summary.total_qty` | Total pcs ever purchased |
| `summary.total_cost` | Σ `qty * p_price / 12` |
| `summary.avg/min/max_p_price` | Purchase price stats (per dozen) |
| **Raw columns** | |
| `items[].id` | Purchase row id |
| `items[].p_price` / `w_price` / `s_price` | Prices recorded at purchase time (per dozen) |
| `items[].product_id` | Product id |
| `items[].qty` | Purchased pcs (قەبارەی کڕین) |
| `items[].supplier` | Supplier text |
| `items[].created_at` / `updated_at` | Row timestamps (`created_at` = purchase date, کاتی کڕین) |
| `items[].note` | Purchase note (تێبینی) |
| `items[].container_id` | Container id, `0` = none |
| `items[].inventory_id` | Inventory id, `0` = none |
| **Computed extras** | |
| `items[].date` | Alias of `created_at` |
| `items[].cartons` | `qty / product.pak_qty` |
| `items[].prices` | Same prices as a `prices` object with per‑piece values |
| `items[].total_cost` | `qty * p_price / 12` |
| `items[].container` | Resolved container object or `null` |

**sales**

| Field | Description |
|---|---|
| `sold_qty` | Total pcs sold (چەنەی لێ فرۆشراوە) |
| `sell_count` | Number of bill lines |
| `revenue` / `cost` / `profit` | Σ at sale-time prices |
| `sold_cartons` | `sold_qty / pak_qty` |
| `avg_monthly_qty` | Average pcs/month over last 12 months |
| `stock_cover_months` | `qty / avg_monthly_qty` — how many months current stock lasts; `null` if no sales |
| `by_month[]` | Always exactly 12 entries (oldest → current month), zeros filled |
| `recent[]` | Last 20 sale lines. `type` is `pcs` or `pak` (carton); `qty_pcs` is always pcs |
| `recent[].customer` | Registered customer, or `{id:null, name, phone}` from the bill, or `null` |

**china**

| Field | Description |
|---|---|
| `shop` | Linked China shop (via explicit link, else by code prefix) or `null` |
| `buy` | 1 = marked "to buy" in the China order list |
| `buyed` | 1 = already bought |
| `note` | China order note |

---

## 6. `GET /api/app/products/{id}/prices`

Everything needed to draw price charts and detect duplicates.

```json
{
  "data": {
    "product": ProductBasic,
    "unit": "dozen",

    "timeline": [
      { "date": "2022-06-25 18:36:56", "p_price": 4400, "s_price": 5000, "w_price": 4700, "source": "created" },
      { "date": "2022-12-23 20:13:34", "p_price": 4590, "s_price": 5000, "w_price": 5000, "source": "edit" },
      { "date": "2024-05-21 16:10:39", "p_price": 4500, "s_price": 5000, "w_price": 4750, "source": "purchase" },
      { "date": "2026-10-11 19:57:35", "p_price": 4850, "s_price": 5250, "w_price": 5250, "source": "current" }
    ],

    "purchase_series": [
      { "date": "2026-03-08 18:14:29", "p_price": 4700, "s_price": 5250, "w_price": 5000, "qty": 1914, "supplier": "hersh" }
    ],

    "sell_series": [
      { "month": "2026-06", "avg_s_price": 5240.63, "min_s_price": 5100, "max_s_price": 5250, "sold_qty": 1428 }
    ],

    "stats": {
      "p_price": { "current": 4850, "first": 4400, "min": 4400, "max": 4850, "avg": 4585.83,
                   "change": 450, "change_pct": 10.23, "changes_count": 8, "trend": "up" },
      "s_price": { "...": "same shape" },
      "w_price": { "...": "same shape" }
    },

    "similar_products": [
      {
        "score": 0.85,
        "reasons": ["name_overlap:75%", "same_brand", "same_pak_qty", "similar_price"],
        "product": ProductBasic
      }
    ]
  }
}
```

### Chart guidance

| Series | Use for | X | Y |
|---|---|---|---|
| `timeline` | Main line chart: how buy/sell/wholesale price changed over time | `date` | `p_price`, `s_price`, `w_price` (3 lines) |
| `purchase_series` | Scatter/bar: actual purchase price per buying event; bubble size = `qty` | `date` | `p_price` |
| `sell_series` | Monthly band: real average price customers paid, with min/max range | `month` | `avg_s_price` (+ `min`/`max` band) |

* `timeline` is sorted ascending, consecutive identical points are removed, and it always ends with a
  `source:"current"` point (now). `source` ∈ `created | edit | purchase | current`.
* `stats.*.trend` ∈ `up | down | stable` (stable when |change_pct| < 1). `stats.<field>` is `null`
  when no non‑zero values exist.
* Chart values are per dozen; divide by 12 if you want per‑piece.

### Similar products (کاڵای هاوشێوە)

Candidates are scored 0–1; only `score >= 0.3` are returned, max 15, sorted by score desc.

| Reason | Weight | Meaning |
|---|---|---|
| `same_barcode` | 1.0 | Identical barcode → almost certainly a duplicate |
| `same_name` | 1.0 | Identical name |
| `name_overlap:NN%` | up to 0.6 | Share of common name tokens |
| `same_brand` | 0.15 | Same brand |
| `same_pak_qty` | 0.10 | Same pieces per carton |
| `similar_price` | 0.15 | `s_price` within ±15% |

Suggested UI: `score >= 0.9` → "Possible duplicate"; `0.6–0.9` → "Variant"; below → "Related".

---

## 7. `GET /api/app/china/shops`

All Chinese supplier shops (دوکاندارەکانی چین). Products belong to a shop when the product `code`
contains the shop `code` (e.g. shop `AH-` ↔ products `AH-1`, `AH-25`, …).

### Query parameters

| Param | Default | Values |
|---|---|---|
| `sort` | `code` | `code` (default: letter codes A→Z in natural order, then numeric codes, then empty) \| `id` \| `number` (shop_number) \| `name` \| `products` \| `sold` |

### Response

```json
{
  "data": [
    {
      "id": 12,
      "name": "پلاستیک وردەکان قازەکان [دوکانی فیلەکە]",
      "code": "AH-",
      "code2": "18-",
      "shop_number": "35194",
      "wechat": null,
      "note": null,
      "stats": {
        "products": 375,
        "in_stock_products": 183,
        "out_of_stock_products": 192,
        "stopped_products": 1,
        "remaining_qty": 39163,
        "remaining_cartons": 281.35,
        "stock_value": 34072495.42,
        "sold_qty": 412096,
        "to_buy": 14,
        "bought": 14
      },
      "created_at": "2024-08-25 02:09:37",
      "updated_at": "2024-08-25 02:09:37"
    }
  ],
  "meta": { "total": 167, "sort": "code" }
}
```

| Field | Description |
|---|---|
| `code` | Product-code prefix used for matching |
| `code2` | Secondary numeric code |
| `shop_number` | Shop/booth number in the market |
| `wechat` | WeChat id |
| `stats.products` | Number of products from this shop |
| `stats.remaining_qty` | Σ current stock pcs (چەنەی ماوە) |
| `stats.remaining_cartons` | Σ `qty / pak_qty` |
| `stats.stock_value` | Σ `qty * p_price / 12` |
| `stats.sold_qty` | Σ pcs sold of this shop's products (چەنەی لێ فرۆشراوە) |
| `stats.to_buy` / `bought` | Count of products flagged `buy` / `buyed` in the China order list |

---

## 8. `GET /api/app/china/shops/{id}`

One shop with **every product**, its sold / remaining / purchase numbers, and a per‑product
**sales‑performance analysis** (`analysis`, see §8.1).

### Query parameters

| Param | Default | Values |
|---|---|---|
| `filter` | `all` | `all` \| `buyed` \| `not_buyed` (buy=1 & buyed=0) \| `in_stock` \| `out_of_stock` |
| `rating` | — | Comma list of ratings to keep, e.g. `weak,dead` or `excellent,good` |
| `sort` | `code` | `code` \| `score` (best first) \| `sold` \| `remaining` \| `name` |

### Response

```json
{
  "data": {
    "shop": { "id": 12, "name": "...", "code": "AH-", "code2": "18-", "shop_number": "35194",
              "wechat": null, "note": null, "created_at": "...", "updated_at": "..." },
    "summary": {
      "products": 375,
      "remaining_qty": 39163,
      "remaining_cartons": 281.35,
      "stock_value": 34072495.42,
      "sold_qty": 412096,
      "sold_revenue": 369236735.31,
      "sold_profit": 59930172.8,
      "purchased_qty": 321312,
      "to_buy": 17,
      "bought": 17,
      "good_sellers": 223,
      "good_sellers_pct": 59.5,
      "avg_score": 58.1,
      "ratings":         { "excellent": 76, "good": 147, "average": 122, "weak": 27, "dead": 3, "new": 0 },
      "recommendations": { "restock": 127, "keep": 96, "watch": 122, "reduce": 27, "clear": 3, "wait": 0 }
    },
    "products": [
      {
        "...ProductBasic fields...": "...",
        "sales": {
          "sold_qty": 2384, "sell_count": 120, "revenue": 464000, "cost": 371200, "profit": 92800,
          "first_sold_at": "2024-07-27 15:04:04", "last_sold_at": "2026-06-06 17:48:22",
          "sold_cartons": 198.67
        },
        "rmb_price": 17.5,
        "cbm": 0.08,
        "purchases": {
          "count": 6, "total_qty": 2400, "first_at": "2024-07-25 19:10:18", "last_at": "2026-03-01 10:00:00",
          "last": {
            "id": 16490,
            "date": "2026-03-01 10:00:00",
            "qty": 80,
            "cartons": 5,
            "rmb_price": 17.5,
            "cbm": 0.08,
            "total_cbm": 0.4,
            "rmb_cbm_source": "purchase_note",
            "p_price": 55680, "s_price": 72000, "w_price": 69000,
            "total_cost": 371200,
            "supplier": "R-24",
            "note": "یەکەم کڕین RMB:17.5 - CBM:0.0800",
            "container": { "id": 23, "name": "FFAU5897603", "code": "R-24", "type": "china", "usd": 1450,
                           "date_load": "2025-08-02 03:00:00", "date_down": "2025-10-26 03:00:00" }
          }
        },
        "china": { "buy": 0, "buyed": 0, "note": null },
        "analysis": {
          "score": 87,
          "rating": "excellent",
          "rating_ku": "زۆر باش",
          "color": "#16a34a",
          "is_good_seller": true,
          "recommendation": "restock",
          "recommendation_ku": "پێویستە بکڕدرێتەوە",
          "trend": "stable",
          "metrics": {
            "sold_qty": 2384,
            "remaining_qty": 0,
            "purchased_qty": 2400,
            "sell_through_pct": 100,
            "first_available_at": "2024-07-25 19:10:18",
            "months_available": 26.9,
            "first_sold_at": "2024-07-27 15:04:04",
            "last_sold_at": "2026-06-06 17:48:22",
            "months_since_last_sale": 0.3,
            "active_months": 24,
            "consistency_pct": 88.9,
            "avg_monthly_qty": 88.6,
            "qty_last_30": 60,
            "qty_last_90": 210,
            "qty_prev_90": 200,
            "qty_last_180": 410,
            "qty_last_365": 980,
            "bills": 118,
            "unique_customers": 66,
            "repeat_customers": 9,
            "restocks": 6,
            "first_purchase_at": "2024-07-25 19:10:18",
            "last_purchase_at": "2026-03-01 10:00:00",
            "months_since_last_purchase": 7.3,
            "stock_cover_months": 0
          },
          "reasons": [
            { "code": "repeated_restock", "text": "6 جار کڕاوەتەوە — خواستی بەردەوامی هەیە", "positive": true },
            { "code": "consistent_sales", "text": "لە 24 مانگ لە 27 مانگدا فرۆشی هەبووە — فرۆشی بەردەوامە", "positive": true },
            { "code": "many_customers",   "text": "66 کڕیاری جیاواز ئەم کاڵایەیان کڕیوە", "positive": true },
            { "code": "out_of_stock_selling", "text": "ستۆک تەواو بووە بەڵام لەم ٩٠ ڕۆژەدا فرۆشراوە — پێویستە بکڕدرێتەوە", "positive": true }
          ]
        }
      }
    ]
  },
  "meta": { "filter": "all", "rating": [], "sort": "code", "total": 375 }
}
```

Each product is a `ProductBasic` with an **extended** `sales` object plus `rmb_price`, `cbm`,
`purchases` (with `last`), `china` and `analysis` sections. The same `analysis` object is also
returned by `GET /api/app/products/{id}` (as `data.analysis`).

**Product‑level China fields**

| Field | Meaning |
|---|---|
| `rmb_price` | Current buy price in **RMB (¥)** as stored on the product (نرخی کڕین بە یوان) |
| `cbm` | Current cubic metres **per carton** (قەبارە) |

**`purchases.last` — the most recent purchase of this product** (`null` if never purchased)

| Field | Meaning |
|---|---|
| `date` | When it was last bought (کۆتا کڕین) |
| `qty` / `cartons` | Quantity bought (pcs / cartons) |
| `rmb_price` | RMB price **at that purchase** |
| `cbm` | CBM per carton at that purchase |
| `total_cbm` | `cbm × cartons` — volume of that purchase |
| `rmb_cbm_source` | `purchase_note` = parsed from the purchase note (`RMB:x - CBM:y`, written by container imports) → exact historical value · `product` = note had no RMB/CBM, so the product's current `rmb_price`/`cbm` are used |
| `p_price` / `s_price` / `w_price` | IQD prices per dozen recorded at that purchase |
| `total_cost` | `qty × p_price / 12` |
| `supplier` / `note` | Raw purchase fields |
| `container` | Shipment container (`usd` = exchange rate used) or `null` |

### 8.1 The `analysis` object — how a product is judged

The server looks at *how much* was sold, *how much is left*, *how many different customers* bought it,
*how often it was restocked*, and *how the sales are spread over time*. A product bought once long
ago and barely moving is a weak seller; a product bought many times with sales in most months is a
good seller.

| Field | Type | Meaning |
|---|---|---|
| `score` | int 0–100 | Overall sales-performance score |
| `rating` | enum | `excellent` ≥75 · `good` ≥55 · `average` ≥35 · `weak` ≥15 · `dead` <15 or never sold · `new` (available < 2 months, not enough data) |
| `rating_ku` | string | Kurdish label: زۆر باش / باش / مامناوەند / لاواز / زۆر لاواز / نوێ |
| `color` | hex | Suggested badge colour (green → red, blue for `new`) |
| `is_good_seller` | bool | `true` when rating is `excellent` or `good` — **the yes/no answer to "does it sell well?"** |
| `recommendation` | enum | `restock` · `keep` · `watch` · `reduce` · `clear` · `wait` |
| `recommendation_ku` | string | Kurdish action text |
| `trend` | enum | `up` / `down` / `stable` / `none` — last 90 days vs the 90 days before |
| `reasons[]` | list | Human explanations. `code` is stable for logic, `text` is Kurdish for display, `positive` is `true` (good), `false` (bad) or `null` (neutral) |

**`metrics`** (all quantities in pcs, months as decimals):

| Field | Meaning |
|---|---|
| `sold_qty` / `remaining_qty` / `purchased_qty` | Total sold, current stock, total ever purchased |
| `sell_through_pct` | `sold / (sold + remaining) × 100` |
| `first_available_at` | Earliest of: product created, first purchase, first sale |
| `months_available` | Months since `first_available_at` (min 1) |
| `first_sold_at` / `last_sold_at` / `months_since_last_sale` | Sales date range |
| `active_months` | Number of distinct months with at least one sale |
| `consistency_pct` | `active_months / months_available × 100` — how regularly it sells |
| `avg_monthly_qty` | `sold_qty / months_available` |
| `qty_last_30/90/180/365` | Pcs sold in the recent windows; `qty_prev_90` = days 90–180 ago |
| `bills` | Number of distinct invoices containing the product |
| `unique_customers` | Distinct buyers (registered customer id, else guest phone/bill) |
| `repeat_customers` | Buyers who bought it on 2+ invoices |
| `restocks` | Number of purchase records (how many times it was bought from the supplier) |
| `first_purchase_at` / `last_purchase_at` / `months_since_last_purchase` | Purchase date range |
| `stock_cover_months` | `remaining / (qty_last_180 / 6)` — months the stock will last; `0` when out of stock, `null` when no recent sales |

**Score composition** (for transparency; the client does not need to recompute it):

| Component | Max | Based on |
|---|---|---|
| Consistency | 30 | `consistency_pct` |
| Sell‑through | 20 | `sell_through_pct` |
| Customer breadth | 15 | `unique_customers` (full marks at 15+) |
| Repeat buyers | 5 | `repeat_customers` (full marks at 5+) |
| Restocks | 15 | `restocks − 1` (full marks at 4+ purchases) |
| Recency | 15 | Full if sold within 1 month, fades to 0 at 12 months |
| Trend | ±5 | `up` +5, `down` −5 |

**Reason codes**

| Code | positive | Fires when |
|---|---|---|
| `new_product` | null | Available < 2 months |
| `never_sold` | false | 0 sold after ≥ 2 months |
| `single_purchase_slow` | false | Bought ≤ 1 time, ≥ 6 months available, < 50 % sold |
| `repeated_restock` | true | Restocked ≥ 3 times |
| `consistent_sales` | true | Sales in ≥ 60 % of months (and ≥ 3 active months) |
| `sporadic_sales` | false | Sales in < 30 % of months after ≥ 6 months |
| `many_customers` | true | ≥ 15 unique customers |
| `few_customers` | false | ≤ 3 unique customers after ≥ 6 months |
| `repeat_customers` | true | ≥ 3 repeat buyers |
| `high_sell_through` | true | ≥ 80 % sold |
| `no_recent_sales` | false | Nothing sold for ≥ 6 months |
| `selling_now` | true | Sold something in the last 30 days |
| `trend_up` / `trend_down` | true / false | Last 90 days vs previous 90 days |
| `out_of_stock_selling` | true | Stock is 0 but sold in last 90 days → restock |
| `overstock` | false | Stock covers > 12 months |
| `low_stock_cover` | true | Good seller with ≤ 1 month of stock |

**Recommendation logic**

| Recommendation | When |
|---|---|
| `wait` | rating `new` |
| `restock` | out of stock but selling, **or** good/excellent with ≤ 2 months of stock cover |
| `keep` | excellent / good |
| `watch` | average |
| `reduce` | weak |
| `clear` | dead (sell off remaining stock, don't reorder) |

---

## 8b. Sea container tracking (ShipsGo) — `/api/app/tracking`

A **standalone** list of container numbers the owner wants to follow at sea. It is *not* linked to the
purchasing "containers" section of the website or to products — the user simply types a container
number and the app shows where it is. Tracking data comes from [ShipsGo](https://shipsgo.com)
(ocean API v2).

How it works on the server:

* `POST /api/app/tracking` saves the number in `tracked_containers` and registers it on ShipsGo
  (one ShipsGo credit for a new shipment; if ShipsGo already has it, no credit is used).
* ShipsGo keeps checking the carrier; the server caches the last payload and refreshes it
  automatically every 6 hours (`php artisan shipsgo:refresh --notify`, which also posts status/ETA
  changes to Telegram). The app therefore reads **cached data instantly**; add `refresh=1` to force
  a live fetch (cache window = `SHIPSGO_CACHE_MINUTES`, default 60).
* `POST /api/app/tracking/import` pulls everything already present in the ShipsGo account.

### TrackedContainer object

```json
{
  "id": 2,
  "container_number": "OOCU8883176",
  "booking_number": null,
  "name": "کۆنتەینەری مێرسین ١",
  "carrier": "COSU",
  "note": null,
  "archived": false,
  "shipsgo_id": 6870586,
  "status": "SAILING",
  "status_ku": "لە دەریادایە",
  "is_finished": false,
  "checked_at": "2026-10-12 00:35:10",
  "error": null,
  "tracking": {
    "shipsgo_id": 6870586,
    "reference": "dos-2",
    "container_number": "OOCU8883176",
    "booking_number": null,
    "container_count": 1,
    "carrier": { "scac": "COSU", "name": "COSCO" },
    "status": "SAILING",
    "status_ku": "لە دەریادایە",
    "is_active": true,
    "route": {
      "port_of_loading":   { "code": "CNNGB", "name": "NINGBO", "country": "China",  "country_code": "CN", "timezone": "Asia/Shanghai" },
      "date_of_loading": "2026-10-08T12:00:00+08:00",
      "date_of_loading_initial": "2026-10-08T12:00:00+08:00",
      "port_of_discharge": { "code": "TRMER", "name": "MERSIN", "country": "Turkey", "country_code": "TR", "timezone": "Europe/Istanbul" },
      "eta": "2026-11-15T12:00:00+03:00",
      "eta_initial": "2026-11-15T12:00:00+03:00",
      "eta_predicted": null,
      "delay_days": 0,
      "days_to_arrival": 35,
      "transshipments": 0,
      "transit_time_days": 38,
      "transit_percentage": 10,
      "co2_emission_tons": 1.9
    },
    "vessel": { "name": "CSCL JUPITER", "imo": 9467251, "voyage": "095W" },
    "containers": [
      {
        "number": "OOCU8883176", "status": "SAILING", "status_ku": "لە دەریادایە", "size": 40, "type": "HC",
        "movements": [
          { "event": "LOAD", "event_ku": "بارکردن لە کەشتی", "actual": true,  "timestamp": "2026-10-08T12:00:00+08:00", "location": { "code": "CNNGB", "name": "NINGBO", "country": "China", "country_code": "CN", "timezone": "Asia/Shanghai" }, "vessel": "CSCL JUPITER", "vessel_imo": 9467251, "voyage": "095W" },
          { "event": "DEPA", "event_ku": "ڕۆیشتنی کەشتی",    "actual": true,  "timestamp": "2026-10-08T12:00:00+08:00", "location": { "...": "..." }, "vessel": "CSCL JUPITER", "vessel_imo": 9467251, "voyage": "095W" },
          { "event": "ARRV", "event_ku": "گەیشتنی کەشتی",    "actual": false, "timestamp": "2026-11-15T12:00:00+03:00", "location": { "code": "TRMER", "name": "MERSIN", "...": "..." }, "vessel": "CSCL JUPITER", "vessel_imo": 9467251, "voyage": "095W" },
          { "event": "DISC", "event_ku": "دابەزاندن لە کەشتی", "actual": false, "timestamp": "2026-11-15T12:00:00+03:00", "location": { "...": "..." }, "vessel": "CSCL JUPITER", "vessel_imo": 9467251, "voyage": "095W" }
        ]
      }
    ],
    "map_url": "https://map.shipsgo.com/ocean/shipments/6870586?token=…",
    "tags": ["dos_db"],
    "shipsgo_updated_at": "2026-10-09 03:12:44",
    "shipsgo_checked_at": "2026-10-12 00:10:02"
  },
  "created_at": "2026-10-12 00:33:01",
  "updated_at": "2026-10-12 00:35:10"
}
```

| Field | Meaning |
|---|---|
| `container_number` | ISO 6346 number (4 letters + 7 digits), normalised to upper‑case without spaces |
| `booking_number` | Optional booking / MBL number |
| `name` | Free label the user gives (e.g. "کۆنتەینەری مێرسین ١") |
| `carrier` | SCAC code (COSU, MSCU, OOLU…) — optional, helps ShipsGo detect faster |
| `note` | Free note |
| `archived` | Hidden from the default list (user archived it) |
| `shipsgo_id` | ShipsGo shipment id; `null` if registration failed (see `error`) — the server retries on next open/refresh |
| `status` / `status_ku` | Shipment status (table below) |
| `is_finished` | `true` when `DISCHARGED` |
| `checked_at` | When the server last refreshed from ShipsGo |
| `error` | Last ShipsGo error (e.g. not enough credits) or `null` |
| `tracking` | Normalised ShipsGo payload; `null` until first successful fetch |

**Shipment `status` values**

| status | status_ku | Meaning |
|---|---|---|
| `NEW` | نوێ — هێشتا پشکنین نەکراوە | Just registered, ShipsGo hasn't checked yet |
| `INPROGRESS` | چاوەڕێی زانیاری کۆمپانیای گواستنەوە | Carrier has no data yet; ShipsGo keeps checking |
| `BOOKED` | بوک کراوە — هێشتا بار نەکراوە | Booked, not loaded |
| `LOADED` | بارکراوە لە کەشتی | On the vessel, not departed |
| `SAILING` | لە دەریادایە | **At sea** |
| `ARRIVED` | گەیشتووەتە بەندەر | Vessel at destination port, not yet discharged |
| `DISCHARGED` | دابەزێنراوە لە بەندەر | Unloaded at destination — tracking finished |
| `UNTRACKED` | ناتوانرێت تراک بکرێت | Invalid number or carrier has no public tracking |

**`tracking.route`**

| Field | Meaning |
|---|---|
| `eta` | Current expected discharge date at destination (کاتی گەیشتن), ISO 8601 with offset |
| `eta_initial` | First ETA the carrier announced |
| `eta_predicted` | ShipsGo ML prediction (may be `null`) |
| `delay_days` | `eta − eta_initial` in days (positive = late, negative = early) |
| `days_to_arrival` | Days from now until `eta`; `null` once arrived/discharged |
| `transit_percentage` | 0–100 progress of the voyage → progress bar |
| `transshipments` | Number of vessel changes en route |

`vessel` is the ship currently carrying the container (from the latest actual LOAD/DEPA movement).
`containers[].movements[]` is the timeline; `actual=false` means an estimate (future). `event` codes:
`EMSH` empty to shipper · `GTIN` gate in · `LOAD` loaded · `DEPA` departed · `ARRV` arrived ·
`DISC` discharged · `GTOT` gate out · `EMRT` empty returned (Kurdish in `event_ku`).

> Note: timestamps inside `tracking` come from ShipsGo in **ISO 8601 with the port's timezone**
> (e.g. `2026-11-15T12:00:00+03:00`), unlike the rest of this API. Parse them as proper date‑times.

### `GET /api/app/tracking`

| Param | Default | Values |
|---|---|---|
| `filter` | `active` | `active` (not finished, not archived) · `all` (not archived) · `finished` · `archived` · `error` |
| `q` | — | Search in number / booking / name / note |
| `refresh` | 0 | `1` = refresh stale rows before answering (slower) |

Response: `{ "data": [TrackedContainer…], "meta": { "filter", "q", "total", "refreshed",
"shipsgo_configured", "status_counts": {"SAILING": 2, "INPROGRESS": 1}, "status_labels": {…} } }`.
In the list, `tracking.containers[].movements` is omitted to keep the payload small.

### `POST /api/app/tracking`

Form / JSON body:

| Field | Required | Notes |
|---|---|---|
| `container_number` | ✔ | Any format; spaces/dashes are stripped, must become 4 letters + 7 digits |
| `name` | | Label |
| `booking_number` | | |
| `carrier` | | SCAC from `/api/app/shipsgo/carriers` |
| `note` | | |

Responses: `201 { "data": TrackedContainer, "meta": { "created": true|false } }` (`created=false`
→ ShipsGo already had it, no credit used) · `200` with `meta.message = "already tracked"` when the
number is already in the list · `422` invalid number · `500` token missing · `502` ShipsGo error
(message in `error`; the row is still saved with `shipsgo_id = null` and will be retried).

### `GET /api/app/tracking/{id}`

`refresh=1` forces a live fetch. Response `{ "data": TrackedContainer (full movements), "meta": { "refreshed", "shipsgo_configured" } }`.

### `POST /api/app/tracking/{id}`

Any of `name`, `note`, `archived` (`1`/`0`). Returns the updated row.

### `POST /api/app/tracking/{id}/delete`

`delete_remote=1` to also delete the shipment on ShipsGo. Returns `{ "data": { "id", "deleted": true } }`.

### `GET /api/app/tracking/{id}/map`

Returns `{ "data": <GeoJSON FeatureCollection>, "meta": { "map_url" } }` — ports as `Point`
features, vessel position, past/future path as `LineString`. Feed `data` directly to Leaflet /
Mapbox / Google Maps, or simply open `meta.map_url` in a WebView (public ShipsGo map page).
Errors: `409` not registered yet, `502` ShipsGo failure.

### `POST /api/app/tracking/import`

Imports every shipment already in the ShipsGo account (free). `{ "data": { "imported": 3, "total": 3 } }`.

### `GET /api/app/shipsgo/carriers`

`{ "data": [ { "scac": "COSU", "name": "COSCO", … } ], "meta": { "total" } }`.

### Suggested UI

* **List** (tab "تراکینگ"): card per row — `name` (or number), `container_number`, status badge
  (`status_ku`; SAILING blue, ARRIVED/DISCHARGED green, INPROGRESS/NEW grey, UNTRACKED/error red),
  progress bar `transit_percentage`, "{POL} → {POD}", "گەیشتن: {eta date} ({days_to_arrival} ڕۆژ)",
  delay chip when `delay_days > 0`, vessel name. Pull‑to‑refresh → `refresh=1`.
  Chips: active / all / finished / archived. FAB "+" → add form.
* **Add form**: container number (auto‑uppercase), optional name, carrier dropdown
  (`/api/app/shipsgo/carriers`), note. Show a confirm "ئەمە یەک کرێدیتی ShipsGo بەکاردەهێنێت".
* **Detail**: route header POL → POD with flags (`country_code`), ETA block (eta / eta_initial /
  delay), vessel + voyage, vertical timeline from `movements` (filled dot = `actual`, hollow =
  estimate), "نەخشە" → WebView `map_url` (or native map from `/map`), edit name/note, archive, delete.
* Show `error` prominently when non‑null (e.g. ShipsGo credits exhausted).

---

## 9. Client implementation notes

1. **Lists** (endpoints 1, 5): render `images.thumb`, `name`, `code`, `prices.s_price`,
   `stock.qty` + `stock.cartons`, `sales.sold_qty`. Mark `stock.low_stock` and `stock.qty == 0`.
2. **Detail** (endpoint 2): use `images.medium`. Show the three price values both per dozen and per
   piece. Purchases list = `purchases.items`; sales history = `sales.by_month` (bar chart) and
   `sales.recent` (list).
3. **Prices tab** (endpoint 3): line chart from `timeline`, overlay `purchase_series` as dots, and a
   second chart from `sell_series`. Show `stats` as summary cards with the `trend` arrow. List
   `similar_products` beneath with the `reasons` as chips.
4. **Pagination**: stop when `meta.page >= meta.last_page`.
5. **Caching**: endpoint 4 is relatively heavy (~0.5 s); cache it for a few minutes on the client.
6. **Numbers** may come back as integers or floats; always parse as double.
7. **Nullables**: `arabic, code, barcode, brand, supplier, note, images.*, cartons, margin_pct,
   last_sold_at, stock_cover_months, container, china.shop, stats.<field>` can be `null`.

### Quick test with curl

```bash
KEY=your_key
B=https://your-server

curl -G "$B/api/app/products/search" --data-urlencode "q=گسکی" --data-urlencode "key=$KEY"
curl -G "$B/api/app/products/716" --data-urlencode "key=$KEY"
curl -G "$B/api/app/products/716/prices" --data-urlencode "key=$KEY"
curl -G "$B/api/app/china/shops" --data-urlencode "sort=sold" --data-urlencode "key=$KEY"
curl -G "$B/api/app/china/shops/12" --data-urlencode "filter=in_stock" --data-urlencode "key=$KEY"
```

### Minimal Dart (Flutter) example

```dart
import 'dart:convert';
import 'package:http/http.dart' as http;

class DosApi {
  DosApi(this.baseUrl, this.key);
  final String baseUrl;
  final String key;

  Future<Map<String, dynamic>> _get(String path, [Map<String, String>? q]) async {
    final uri = Uri.parse('$baseUrl/api/app$path').replace(queryParameters: {...?q});
    final res = await http.get(uri, headers: {'X-Api-Key': key});
    if (res.statusCode != 200) {
      throw Exception('API ${res.statusCode}: ${res.body}');
    }
    return jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
  }

  Future<Map<String, dynamic>> searchProducts(String q, {int page = 1, String sort = 'newest'}) =>
      _get('/products/search', {'q': q, 'page': '$page', 'sort': sort});

  Future<Map<String, dynamic>> product(int id) => _get('/products/$id');
  Future<Map<String, dynamic>> productPrices(int id) => _get('/products/$id/prices');
  Future<Map<String, dynamic>> chinaShops({String sort = 'id'}) => _get('/china/shops', {'sort': sort});
  Future<Map<String, dynamic>> chinaShop(int id, {String filter = 'all'}) =>
      _get('/china/shops/$id', {'filter': filter});
}
```

---

## 10. Glossary (Kurdish ↔ API)

| Kurdish | API field |
|---|---|
| بڕی ئێستا | `stock.qty` |
| بڕی ناو کارتۆن | `stock.pak_qty` |
| ژمارەی کارتۆن | `stock.cartons` |
| نرخی کڕین | `prices.p_price` / `purchases.items[].prices.p_price` |
| نرخی فرۆشتن | `prices.s_price` |
| نرخی کۆمەڵ | `prices.w_price` |
| قەبارە (CBM) | `stock.cbm` |
| کاتی کڕین | `purchases.items[].date` |
| تێبینی | `note`, `purchases.items[].note`, `china.note` |
| چەنەی فرۆشراوە | `sales.sold_qty` |
| چەنەی ماوە | `stock.qty` / `stats.remaining_qty` |
| دوکاندارەکانی چین | `/api/app/china/shops` |
| کاڵای هاوشێوە | `similar_products` |
