{"total":229,"tools":[{"name":"list_selling_products","description":"GET /api/v2/products. Why: The menu the session branch sells — exactly what web-pos loads: live products of this branch with the branch price (actual_price, VAT incl.), option-group links (modifiers[]), sold-out flag and combo slots. Use it to show a menu or build a cart; use list_products only to manage the catalog. Paginated: loop current_page until you have total_records rows. Pair with list_selling_modifiers (option groups) and list_selling_addons (add-ons). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_selling_products\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/products","why":"The menu the session branch sells — exactly what web-pos loads: live products of this branch with the branch price (actual_price, VAT incl.), option-group links (modifiers[]), sold-out flag and combo slots. Use it to show a menu or build a cart; use list_products only to manage the catalog. Paginated: loop current_page until you have total_records rows. Pair with list_selling_modifiers (option groups) and list_selling_addons (add-ons).","response":{"description":"{ status, message, data: { current_page, records_per_page, total_records, products[] } }. Branch = the session branch (the API key's linked user). This route has NO branch parameter on master tenants — you cannot read another branch's menu with it. ALWAYS check status: on a server error the same shape comes back with status:false and products:[]. Use actual_price as the price (VAT included); ignore price and vat.","fields":{"status":"boolean. true = success (also when nothing matched). false = server error.","message":"string. \"Data found \" | \"No data found\".","data.current_page":"int. Page returned.","data.records_per_page":"int. Page size used.","data.total_records":"int. Total products across all pages. Pages = ceil(total_records / records_per_page).","data.products":"array. This page of products.","data.products[].id":"int. Product id → product_id on the cart line / invoice item.","data.products[].name":"string. Name to show. Branch name if the branch overrides it, else the master name.","data.products[].actual_price":"number. THE PRICE: per unit, VAT INCLUDED (per kg when is_weightage_item = 1). Show it, charge it, send it as the line's actual_price. Branch price if the branch has its own price, else the master price.","data.products[].type":"string | null. CATEGORY NAME the product is filed under (web-pos filters the menu grid by type == category name, routes kitchen printers by it, and sends it as the invoice line `type`).","data.products[].category_id":"int | null. Category id (secondary link; web-pos uses it only as a fallback filter).","data.products[].category_sort_order":"int | null. Order inside the category grid (ascending). null in most data → keep API order.","data.products[].image":"string. Full image URL, or \"\" = no image.","data.products[].is_sold_out":"0 | 1. 1 = sold out at this branch: show it greyed out and do NOT add it to a cart. Also treat the product as sold out when its quota (list_product_quota) has remaining_stock = 0, and never let cart qty exceed remaining_stock. This flag is a snapshot — list_sold_out_products gives the current list; toggle with update_product_availability.","data.products[].is_open_price":"0 | 1. 1 = the cashier types the price at sale time; that price replaces actual_price.","data.products[].is_weightage_item":"0 | 1 | null. 1 = sold by weight: ask the weight, line total = weight × actual_price.","data.products[].discount_pct":"int | null. MAXIMUM manual discount % a cashier may give on this line. NOT applied automatically; 0/null = no line discount allowed by product.","data.products[].barcode_value":"string | null. Barcode; match scans / searches against it.","data.products[].location_id":"int. The branch this row was resolved for (= session branch).","data.products[].is_addon":"0 | 1. 1 = add-on (only on list_selling_addons).","data.products[].modifiers":"array. Option groups on this product: [{ modifier_id, min_allowed, max_allowed, priority }]. [] = no options. Look each modifier_id up in list_selling_modifiers (data[].id) to get the group name and items. A group that is not found there belongs to another branch — skip it.","data.products[].modifiers[].modifier_id":"int. Group id = list_selling_modifiers data[].id.","data.products[].modifiers[].min_allowed":"int. Minimum picks required in this group. >= 1 = required group: block adding the line until met.","data.products[].modifiers[].max_allowed":"int. Maximum total quantity across the group's items. 0 = unlimited. 1 = pick exactly one (radio). THIS rule wins over the group's own selection_allowed.","data.products[].modifiers[].priority":"int | null. Group order on this product (ascending). web-pos ignores it.","data.products[].is_combo":"0 | 1 | 2. Non-zero = combo/deal: hide from the normal menu; sold through a combo builder, children go in combo_items[].","data.products[].categories":"array — ONLY on is_combo = 1 rows. Combo slots [{ category_id, max_allowed }]: pick up to max_allowed products from each category.","data.products[].combo_price":"number. Extra price added when this product is picked as a combo child. Usually 0.","data.products[].price_levels":"array. Per-channel prices [{ channel_id, channel_name, channel_price, channel_vat, channel_actual_price }]. When selling on a channel (list_selling_channels) use channel_actual_price (VAT incl.) for that channel_id, else actual_price. web-pos does not use channel pricing yet; [] in most data.","data.products[].is_overridden":"0 | 1. 1 = at least one branch has its own price or has this product switched off (informational).","data.products[].is_disabled":"0. Always 0 — disabled products are dropped by the server.","data.products[].is_archive":"0. Always 0 — archived products are dropped by the server.","data.products[].is_deleted":"0. Always 0 — deleted products are dropped by the server.","data.products[].price":"number | null. NOT USED — legacy price column; on master rows it equals actual_price, on add-ons it is the ex-VAT price. Use actual_price.","data.products[].vat":"number | null. NOT USED — VAT AMOUNT (not %) stored inside actual_price at save time; may be stale. Tax is out of scope.","data.products[].tax_id":"int | null. NOT USED — tax-group id (tax out of scope).","data.products[].tax_name":"string | null. NOT USED — tax-group name (tax out of scope).","data.products[].tax_value":"number | null. NOT USED — tax-group VAT % (tax out of scope).","data.products[].stock":"number | null. NOT USED — legacy stock column (usually 0/null). Availability = is_sold_out (+ quota tools).","data.products[].description":"string | null. NOT USED by web-pos — product description (branch override wins). Optional to show.","data.products[].localize_name":"string | null. NOT USED by web-pos — name in the second language (e.g. Arabic).","data.products[].localize_description":"string | null. NOT USED — description in the second language.","data.products[].localized":"string | null. NOT USED — legacy localisation column (null in data).","data.products[].sku_no":"string | null. NOT USED by web-pos — SKU, auto-generated as prod-<customer>-<id>.","data.products[].plu":"int | null. NOT USED — PLU (price look-up) code.","data.products[].label_id":"int | null. NOT USED by web-pos — label id (list_labels), a dashboard tag.","data.products[].barcode_type":"string | null. NOT USED — barcode symbology label.","data.products[].image_path":"string. NOT USED — relative upload path of image (\"\" when none).","data.products[].web_image":"string. NOT USED — same as image_path.","data.products[].customer_id":"int. NOT USED — tenant id (from the session).","data.products[].addons":"string | null. NOT USED — raw JSON-string column saved by the dashboard (e.g. \"[]\"); add-ons are matched by category, not by this.","data.products[].bundle_items":"string | null. NOT USED — raw JSON-string column used by deals.","data.products[].recipes":"string | null. NOT USED — linked ingredient recipe (internal inventory).","data.products[].product_recipe_total":"number | null. NOT USED — recipe cost total (internal inventory).","data.products[].product_material_total":"number | null. NOT USED — material cost total (internal inventory).","data.products[].product_cost":"number | null. NOT USED — cost price (internal).","data.products[].new_cost_date":"string (ISO) | null. NOT USED — when product_cost last changed.","data.products[].is_stock_product":"0 | 1 | null. NOT USED — tracked-as-stock flag (internal inventory).","data.products[].calories":"int | null. NOT USED — calories (optional to show).","data.products[].old_price":"number | null. NOT USED — previous price (history).","data.products[].base_price":"number | null. NOT USED — legacy base price (null in data).","data.products[].is_auto_apply":"0 | 1 | null. NOT USED — legacy flag (null in data).","data.products[].sync_deliverect":"0 | 1. NOT USED — synced to the Deliverect integration.","data.products[].sync_otter":"0 | 1. NOT USED — synced to the Otter integration.","data.products[].unit_id":"int | null. NOT USED on the main line (web-pos sends 0) — unit of measure for weight items.","data.products[].preparation_time":"int | null. NOT USED — kitchen prep time (web-pos sends 0 on the invoice).","data.products[].menu_sort_order":"int. NOT USED for the menu grid — server sets it to category_sort_order || 0; web-pos only sorts combos by it (0 last).","data.products[].auto_selected_modifier_items":"int[]. Item ids flagged default/mandatory for this product — pre-tick them in the option picker. [] in current data."},"master_rules":["Master tenants keep one master product and one row per branch (products_locations).","A product is returned only if it has a row for the session branch (or a shared row with no branch).","Products switched off at the branch (is_available = 0) are NOT returned at all; sold-out ones ARE returned with is_sold_out = 1.","Branch row wins for name, description, actual_price and is_sold_out.","modifiers[] links: the branch-specific link wins, then the shared link, then a link made for another branch.","Menu scoping (web-pos): brand → groups → category ids → categories → products. Products carry NO brand_id; a brand reaches its products only through its groups' category ids.","Allowed category ids = the selected group's categories (web-pos always selects a group when the brand has groups — default the FIRST group); if that group's list is empty, the union of all the brand's groups; if the brand has no groups, no scoping (everything).","Categories NOT in the allowed ids are hidden — including categories that belong to no group at all. If scoping would hide every category, web-pos shows all (fail-open).","Option picker (web-pos): pre-tick the product's auto_selected_modifier_items (at most max_allowed per group); if a group has none and min_allowed >= 1 and max_allowed = 1, pre-tick the group's FIRST item. An option can have quantity > 1 only when max_allowed is 0 or > 1; the total quantity across a group must stay <= max_allowed (when > 0).","Cart merge (web-pos): adding the same product with identical options, add-ons, extras and combo children (and, for is_open_price, the same typed price) increases qty on the existing cart line instead of adding a new line.","Products in a category (web-pos grid): rows whose type EXACTLY equals the category name (case-sensitive); only if none, rows whose category_id = the category id or whose type matches case-insensitively. Then drop combos (is_combo != 0) and rows whose category_id is not in the allowed ids (rows without a category_id pass). Sort ascending by category_sort_order (null = 0), ties in id order."],"example":{"status":true,"message":"Data found ","data":{"current_page":1,"records_per_page":50,"total_records":1066,"products":[{"id":257509,"category_id":15430,"name":"Vanilla Latte","description":"Authentic Flavors","price":10,"stock":0,"type":"Specialty & Flavored Coffees","image":"","customer_id":437,"location_id":907,"is_addon":0,"addons":"[]","bundle_items":"[]","is_disabled":0,"discount_pct":0,"modifiers":[{"priority":3,"max_allowed":3,"min_allowed":0,"modifier_id":3687},{"priority":2,"max_allowed":3,"min_allowed":0,"modifier_id":3703},{"priority":1,"max_allowed":3,"min_allowed":0,"modifier_id":3710},{"priority":4,"max_allowed":3,"min_allowed":0,"modifier_id":3986},{"priority":5,"max_allowed":3,"min_allowed":0,"modifier_id":3987},{"priority":6,"max_allowed":3,"min_allowed":0,"modifier_id":3988},{"priority":7,"max_allowed":3,"min_allowed":0,"modifier_id":3989},{"priority":5,"max_allowed":1,"min_allowed":0,"modifier_id":5622}],"is_archive":0,"barcode_type":null,"barcode_value":null,"is_open_price":0,"is_combo":0,"sync_deliverect":0,"sync_otter":0,"new_cost_date":null,"product_cost":null,"is_weightage_item":0,"unit_id":0,"actual_price":10,"old_price":null,"vat":0.48,"localized":null,"base_price":null,"menu_sort_order":0,"plu":null,"calories":null,"is_stock_product":0,"is_auto_apply":null,"combo_price":0,"localize_name":"Vanilla Latte","localize_description":"Coffee","label_id":50,"recipes":null,"sku_no":"prod-437-257509","tax_id":16,"preparation_time":0,"product_recipe_total":0,"product_material_total":0,"is_overridden":0,"is_deleted":0,"category_sort_order":null,"is_sold_out":0,"auto_selected_modifier_items":[],"price_levels":[],"tax_name":"Food Card Tax","tax_value":5,"image_path":"","web_image":""}]}}},"parameters":{"type":"object","properties":{"page_limit":{"type":"number","description":"Optional."},"current_page":{"type":"number","description":"Optional."},"k":{"type":"string","description":"Optional."},"cat":{"type":"string","description":"Optional."},"addon":{"type":"string","enum":["0","1"],"description":"Optional."}},"required":[]},"module":"products","safety":"read"},{"name":"list_products","description":"GET /api/products/webProducts. Why: Catalog management view: every product of the tenant with its master price, category, label, branch list and recipe/channel flags — this is the dashboard product list, not the POS selling menu (use list_selling_products to show or sell from a branch menu; it has branch prices, sold-out and modifier links). Use it to search/filter products and get ids for get_product → update_product / archive_product / delete_product. Filters take ids from list_categories (category_id), list_brands (brand_id), list_labels (label_id), list_channels (channel_id), list_locations (loc). `type` is the category name the POS files the product under (list_selling_categories, sync_invoice line type). Add-ons: list_addons. Per-branch prices, modifier links, BOM and recipes are NOT here — call get_product. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_products\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/products/webProducts","why":"Catalog management view: every product of the tenant with its master price, category, label, branch list and recipe/channel flags — this is the dashboard product list, not the POS selling menu (use list_selling_products to show or sell from a branch menu; it has branch prices, sold-out and modifier links). Use it to search/filter products and get ids for get_product → update_product / archive_product / delete_product. Filters take ids from list_categories (category_id), list_brands (brand_id), list_labels (label_id), list_channels (channel_id), list_locations (loc). `type` is the category name the POS files the product under (list_selling_categories, sync_invoice line type). Add-ons: list_addons. Per-branch prices, modifier links, BOM and recipes are NOT here — call get_product.","response":{"description":"200 → { data, total }. data = one object per product: every raw products column (SELECT *), plus leftover override_* columns from one joined branch row, image_path/web_image, channel_price[], nameChannels, total_locations and has_recipe. Server filters: tenant = session, is_disabled = 0, is_combo = 0, is_deleted = 0, is_archive = <is_archive>, is_addon = 1 only with addon=\"1\" else 0, plus k/category/brand/label/channel/loc filters. No ORDER BY in master mode (rows come back roughly by id). Errors → 500 { status: false, message: \"Internal Server Error\", error }.","fields":{"data":"array. Products on this page (whole list when limit ≥ 10000, which MCP sends by default).","data[].id":"int. Product id. Pass as `id` to get_product / update_product / archive_product / delete_product.","data[].category_id":"int | null. Primary category id (single INT column; see list_categories / get_category). Filter list_products with category_id.","data[].name":"string. Product name; if the product has branch rows, the name of one (arbitrary) branch row is shown when it is non-empty, else the master name.","data[].description":"string | null. Description, overridden the same way as name.","data[].price":"number. Copy of actual_price in this response (the handler overwrites the column). Use actual_price.","data[].stock":"number | null. Legacy opening-stock column. Not live inventory (see inventory/material tools); not needed.","data[].type":"string | null. Category NAME(s), comma-separated; rebuilt from category_id on every create/update. The POS menu groups by it. Informational.","data[].image":"string. Absolute image URL, or \"\" when the product has no image.","data[].customer_id":"int. Tenant (merchant) id. Always your own tenant; not needed.","data[].location_id":"int | int[]. Branches (products_locations) the product is on: a bare int when exactly one, else an array ([] = on no branch). With `loc`, only the requested branches are listed. Use get_product → location_prices for per-branch prices.","data[].is_addon":"0 | 1. 1 = add-on product (manage via the add-on tools / list_addons).","data[].addons":"string | null. Legacy JSON string (e.g. \"[]\"), not parsed here. Not needed.","data[].bundle_items":"string | null. Legacy JSON string (e.g. \"[]\"). Not needed.","data[].is_disabled":"0. Always 0 — disabled products are filtered out. Not needed.","data[].discount_pct":"number | null. Product-level discount percent 0–100 (0/null = none).","data[].modifiers":"null. Legacy VARCHAR column, always null. Real modifier links are in get_product → modifiers[]; not needed.","data[].is_archive":"0 | 1. Always equals the is_archive filter you sent (default 0).","data[].barcode_type":"string | null. Barcode symbology label as typed in the dashboard (\"\" / null = none).","data[].barcode_value":"string | null. Barcode value scanned at the POS (\"\" / null = none).","data[].is_open_price":"0 | 1. 1 = cashier types the price at sale time.","data[].is_combo":"0. Always 0 — combo products are filtered out (see the deal tools).","data[].sync_deliverect":"0 | 1. Deliverect integration sync flag. Not needed.","data[].sync_otter":"0 | 1. Otter integration sync flag. Not needed.","data[].new_cost_date":"string (ISO timestamp) | null. Last cost-change date (legacy, usually null). Not needed.","data[].product_cost":"number | null. Cost = product_material_total + product_recipe_total, recomputed on every create/update.","data[].is_weightage_item":"0 | 1 | null. 1 = sold by weight (price is per unit_id, e.g. per kg).","data[].unit_id":"int | null. Weight unit id for weighted items (0/null = none).","data[].actual_price":"number. MASTER price, VAT included — the default price every branch gets. Branch-specific prices are in get_product → location_prices[].","data[].old_price":"number | null. Legacy previous price, usually null. Not needed.","data[].vat":"number | null. Tax amount column — tax is out of scope; ignore.","data[].localized":"string | null. Legacy localisation column, usually null. Not needed.","data[].base_price":"number | null. Legacy base price, usually null. Not needed.","data[].menu_sort_order":"int. Sort position inside its category on menus (ascending, default 0).","data[].plu":"int | null. PLU / short code typed on the POS keypad (0/null = none).","data[].calories":"int | null. Calories shown on menus (null = not set).","data[].is_stock_product":"0 | 1 | null. 1 = tracked as a stock item. Informational.","data[].is_auto_apply":"0 | 1 | null. Legacy auto-apply flag, usually null. Not needed.","data[].combo_price":"number. Price when sold inside a combo (default 0).","data[].localize_name":"string | null. Second-language (e.g. Arabic) name.","data[].localize_description":"string | null. Second-language description.","data[].label_id":"int | null. Label id (list_labels / get_label).","data[].recipes":"null. Legacy TEXT column, always null. Real recipe links: get_product → recipes[] (list_products has has_recipe). Not needed.","data[].sku_no":"string. SKU. Master tenants get an auto SKU \"prod-<customer_id>-<id>\" when created without one.","data[].tax_id":"int | null. Tax group id — out of scope; omit.","data[].preparation_time":"int | null. Preparation time as entered in the dashboard (kitchen display hint).","data[].product_recipe_total":"number | null. Recipe cost part of product_cost.","data[].product_material_total":"number | null. Direct-material (BOM) cost part of product_cost.","data[].is_overridden":"0 | 1. 1 = at least one branch has a different price than actual_price or is unavailable (recomputed on save).","data[].is_deleted":"0. Always 0 — deleted products are filtered out.","data[].category_sort_order":"int | null. Legacy category ordering, usually null. Not needed.","data[].override_location_id":"int | null. Branch id of the first products_locations row the SQL join hit (arbitrary branch). Internal leftover; not needed.","data[].override_name":"string | null. That branch row's name (already applied to `name`). Not needed.","data[].override_description":"string | null. That branch row's description. Not needed.","data[].override_price":"number | null. That ONE branch's price — NOT the price at every branch. Use get_product → location_prices instead.","data[].override_actual_price":"number | null. That one branch's VAT-incl. price. Use get_product → location_prices instead.","data[].image_path":"string. Relative upload path of the image (\"\" = none). Pass this (not the absolute URL) as `image` on update_product.","data[].web_image":"string. Same value as image_path.","data[].channel_price":"array. Sales-channel prices of the product ([] = none).","data[].channel_price[].channel_id":"int. Channel id (list_channels).","data[].channel_price[].channel_name":"string. Channel name.","data[].channel_price[].channel_price":"number. Channel price before VAT.","data[].channel_price[].channel_vat":"number. Tax part — tax out of scope; ignore.","data[].channel_price[].channel_actual_price":"number. Channel price VAT included.","data[].nameChannels":"object. Dashboard chip helper derived from channel_price.","data[].nameChannels.channels":"array. One entry per channel_price row.","data[].nameChannels.channels[].channel_id":"int. Channel id (same as channel_price[].channel_id).","data[].nameChannels.channels[].channel_name":"string. Channel name. Redundant with channel_price; not needed.","data[].total_locations":"int. Number of branches the product is on (count of products_locations rows). Caveat: products on NO branch report 1, not 0 — check location_id == [] instead.","data[].has_recipe":"0 | 1. 1 = at least one recipe is linked (details via get_product → recipes).","total":"int. Number of products matching all filters (all pages). Page count = ceil(total / limit)."},"example":{"data":[{"id":257508,"category_id":15231,"name":"Mocha","description":"Authentic Flavors","price":10,"stock":0,"type":"Espresso-Based Drinks","image":"https://dev1.syspos.ae/api/uploads/1780385109749-apple.png","customer_id":437,"location_id":[908,907],"is_addon":0,"addons":"[]","bundle_items":"[]","is_disabled":0,"discount_pct":0,"modifiers":null,"is_archive":0,"barcode_type":"","barcode_value":"","is_open_price":0,"is_combo":0,"sync_deliverect":0,"sync_otter":0,"new_cost_date":null,"product_cost":null,"is_weightage_item":0,"unit_id":0,"actual_price":10,"old_price":null,"vat":0.48,"localized":null,"base_price":null,"menu_sort_order":0,"plu":0,"calories":null,"is_stock_product":0,"is_auto_apply":null,"combo_price":0,"localize_name":"Mocha","localize_description":"Coffee","label_id":50,"recipes":null,"sku_no":"pd-437-257508","tax_id":16,"preparation_time":0,"product_recipe_total":50,"product_material_total":0,"is_overridden":0,"is_deleted":0,"category_sort_order":null,"override_location_id":908,"override_name":"Mocha","override_description":"Authentic Flavors","override_price":10,"override_actual_price":10,"image_path":"1780385109749-apple.png","web_image":"1780385109749-apple.png","channel_price":[],"nameChannels":{"channels":[]},"total_locations":2,"has_recipe":1}],"total":2745},"master_rules":["The dashboard Products page calls this with page (1-based), limit 10/25/50, is_archive (Status filter: Active 0 default, Inactive 1), loc (on first open the user's own branch, else the first branch; the location filter can tick several → csv), and when set: k (search box), category_id (one id), brand_id, label_id, channel_id. It never sends cat or addon. To reproduce a page send the same params; for the whole catalog omit limit (MCP sends 10000).","Each row the dashboard shows: image, name (title-cased) with an \"Override\" chip when is_overridden = 1, sku_no (fallback \"SKU-<id>\"), type (category name), \"<total_locations> Locations\", \"<n> Channels\" from nameChannels.channels, Base Price = actual_price (\"—\" when 0), Cost/Profit/Margin from a separate recipe-cost endpoint (not in this response), Status Active/Inactive from is_archive.","Stats cards come from a second call with the same filters and limit=10000: active count (is_archive = 0), average margin, margin < 20% count and \"no recipe\" count (has_recipe = 0).","Editing starts with get_product (id only) — do the same before update_product; the header sort toggle has no effect and drag-reorder writes menu_sort_order through a bulk endpoint that has no MCP tool.","Master tenant: a product sells at a branch only if get_product → location_prices has that branch with is_available = true; what a branch really sells is list_selling_products (same product ids). location_id [] = on no branch (total_locations then wrongly says 1)."]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id or comma-separated branch ids (list_locations)."},"k":{"type":"string","description":"Case-insensitive substring match on the master product name (products.name, not branch names)."},"category_id":{"type":"string","description":"Category id or comma-separated ids (list_categories); a product matches if its category_id is any of them."},"cat":{"type":"string","description":"Comma-separated category NAMES (exact, case-insensitive, trimmed)."},"addon":{"type":"string","enum":["0","1"],"description":"\"1\" → ONLY add-on products (is_addon = 1)."},"is_archive":{"type":"number","description":"0 → active products, 1 → archived products (never both)."},"brand_id":{"type":"number","description":"Brand id (list_brands): products whose category is in a group linked to that brand."},"label_id":{"type":"number","description":"Label id (list_labels): exact match on label_id."},"channel_id":{"type":"number","description":"Channel id (list_channels): only products that have a price row for this channel."},"page":{"type":"number","description":"1-based page, used only when limit < 10000."},"limit":{"type":"number","description":"Page size."}},"required":[]},"module":"products","safety":"read"},{"name":"get_product","description":"GET /api/products/:id. Why: Full catalog record of one product — per-branch prices (location_prices), modifier links per branch, channel prices, BOM materials and recipes — which list_products does not return. Always call it before update_product: update is not a patch, so you resend location_prices, modifiers, materials, recipes, channel_price and the columns from this record. For selling/menu display use list_selling_products instead. Ids here link to list_locations (location_prices), list_modifiers / list_selling_modifiers and list_modifier_items (modifiers), list_channels (channel_price), list_materials, list_recipes, list_categories (category_id; type = category name), list_labels (label_id). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_product\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/products/:id","why":"Full catalog record of one product — per-branch prices (location_prices), modifier links per branch, channel prices, BOM materials and recipes — which list_products does not return. Always call it before update_product: update is not a patch, so you resend location_prices, modifiers, materials, recipes, channel_price and the columns from this record. For selling/menu display use list_selling_products instead. Ids here link to list_locations (location_prices), list_modifiers / list_selling_modifiers and list_modifier_items (modifiers), list_channels (channel_price), list_materials, list_recipes, list_categories (category_id; type = category name), list_labels (label_id).","response":{"description":"200 → [product]: a JSON array with exactly one object (not { data }). It has every raw products column (archived and even soft-deleted products are returned — check is_archive / is_deleted), location_id as the branch list, image_path/web_image, parsed addons/bundle_items, and the nested modifiers, channel_price, materials, location_prices and recipes arrays. 404 → { message: \"Product not found\" }. 500 → { error: \"Failed to fetch product\", detail }.","fields":{"[0].id":"int. Product id. Pass as `id` to get_product / update_product / archive_product / delete_product.","[0].category_id":"int | null. Primary category id (single INT column; see list_categories / get_category). Filter list_products with category_id.","[0].name":"string. Product name; replaced by the newest branch row's name (restricted to `loc` if sent) when that is non-empty.","[0].description":"string | null. Free-text description.","[0].price":"number. Price EXCLUDING VAT as the dashboard saves it (actual_price / (1 + VAT%/100)); imported rows may hold actual_price. Not used by the POS; use actual_price.","[0].stock":"number | null. Legacy opening-stock column (not live inventory). Resend as stock on update_product.","[0].type":"string | null. Category NAME(s), comma-separated; the POS files the product under it (list_selling_categories name, sync_invoice line type). Resend as type on update_product.","[0].image":"string. Absolute image URL, or \"\" when the product has no image.","[0].customer_id":"int. Tenant (merchant) id. Always your own tenant; not needed.","[0].location_id":"int | int[]. All branches the product is on (products_locations), regardless of loc: a bare int when exactly one, else an array ([] = on no branch).","[0].is_addon":"0 | 1. 1 = add-on product (manage via the add-on tools / list_addons).","[0].addons":"array. Legacy add-on list parsed from JSON (usually []). Not needed.","[0].bundle_items":"array. Legacy bundle list parsed from JSON (usually []). Not needed.","[0].is_disabled":"0 | 1. Legacy hard-disable flag. Not needed.","[0].discount_pct":"number | null. Product-level discount percent 0–100 (0/null = none).","[0].modifiers":"array. Modifier-group links of the product at every branch (one row per group per branch; location_id null = tenant-wide). Ordered by location_id, modifier_id. Send back (edited) as update_product modifiers / modifiers_by_location.","[0].is_archive":"0 | 1. 1 = archived (hidden from menus; restore with archive_product is_archive=\"0\").","[0].barcode_type":"string | null. Barcode symbology label as typed in the dashboard (\"\" / null = none).","[0].barcode_value":"string | null. Barcode value scanned at the POS (\"\" / null = none).","[0].is_open_price":"0 | 1. 1 = cashier types the price at sale time.","[0].is_combo":"0 | 1. 1 = combo/deal product (managed by the deal tools).","[0].sync_deliverect":"0 | 1. Deliverect integration sync flag. Not needed.","[0].sync_otter":"0 | 1. Otter integration sync flag. Not needed.","[0].new_cost_date":"string (ISO timestamp) | null. Last cost-change date (legacy, usually null). Not needed.","[0].product_cost":"number | null. Cost = product_material_total + product_recipe_total, recomputed on every create/update.","[0].is_weightage_item":"0 | 1 | null. 1 = sold by weight (price is per unit_id, e.g. per kg).","[0].unit_id":"int | null. Weight unit id for weighted items (0/null = none).","[0].actual_price":"number. MASTER price, VAT included — the default price every branch gets. Branch-specific prices are in get_product → location_prices[].","[0].old_price":"number | null. Legacy previous price, usually null. Not needed.","[0].vat":"number | null. VAT AMOUNT inside actual_price (actual_price − price), not a %. Not used by the POS.","[0].localized":"string | null. Legacy localisation column, usually null. Not needed.","[0].base_price":"number | null. Legacy base price, usually null. Not needed.","[0].menu_sort_order":"int. Sort position inside its category on menus (ascending, default 0).","[0].plu":"int | null. PLU / short code typed on the POS keypad (0/null = none).","[0].calories":"int | null. Calories shown on menus (null = not set).","[0].is_stock_product":"0 | 1 | null. 1 = tracked as a stock item. Informational.","[0].is_auto_apply":"0 | 1 | null. Legacy auto-apply flag, usually null. Not needed.","[0].combo_price":"number. Price when sold inside a combo (default 0).","[0].localize_name":"string | null. Second-language (e.g. Arabic) name.","[0].localize_description":"string | null. Second-language description.","[0].label_id":"int | null. Label id (list_labels / get_label).","[0].recipes":"array. Linked recipes. Send back as update_product recipes (update wipes links you omit).","[0].sku_no":"string. SKU. Master tenants get an auto SKU \"prod-<customer_id>-<id>\" when created without one.","[0].tax_id":"int | null. Tax group id — out of scope; resend unchanged on update_product (omitted → null).","[0].preparation_time":"int | null. Preparation time as entered in the dashboard (kitchen display hint).","[0].product_recipe_total":"number | null. Recipe cost part of product_cost.","[0].product_material_total":"number | null. Direct-material (BOM) cost part of product_cost.","[0].is_overridden":"0 | 1. 1 = at least one branch has a different price than actual_price or is unavailable (recomputed on save).","[0].is_deleted":"0 | 1. 1 = soft-deleted by delete_product.","[0].category_sort_order":"int | null. Legacy category ordering, usually null. Not needed.","[0].image_path":"string. Relative upload path of the image (\"\" = none). Pass this (not the absolute URL) as `image` on update_product.","[0].web_image":"string. Same value as image_path.","[0].modifiers[].id":"int. product_modifiers row id (link id).","[0].modifiers[].modifier_id":"int. Modifier group id (list_modifiers / get_modifier).","[0].modifiers[].max_allowed":"int. Max options (0 = unlimited).","[0].modifiers[].min_allowed":"int. Min options (0 = optional).","[0].modifiers[].priority":"int. Group display order on the product (ascending).","[0].modifiers[].location_id":"int | null. Branch this link applies to; null = all branches.","[0].modifiers[].customer_id":"int. Tenant id. Not needed.","[0].modifiers[].selection_type":"\"single\" | \"multiple\". How many options can be picked.","[0].modifiers[].auto_selected_modifier_item_ids":"int[]. Modifier item ids pre-selected by default.","[0].modifiers[].mandatory_modifier_item_ids":"int[]. Modifier item ids that cannot be deselected.","[0].modifiers[].unavailable_modifier_item_ids":"int[]. Modifier item ids hidden for this product.","[0].modifiers[].modifier_items_order":"int[]. All item ids of the group in display order (list_modifier_items).","[0].recipes[].id":"int. Recipe id (same as recipe_id).","[0].recipes[].name":"string. Recipe name.","[0].recipes[].yield_qty":"number. Quantity one batch of the recipe yields.","[0].recipes[].mode":"\"phantom\" | \"stockable\". Only phantom recipes can be newly linked; an already-linked stockable one may be kept.","[0].recipes[].recipe_id":"int. Recipe id (list_recipes / get_recipe).","[0].recipes[].product_id":"int. This product id.","[0].recipes[].portion_qty":"number. Portion of the recipe used per product.","[0].recipes[].unit":"string | null. Unit of portion_qty.","[0].recipes[].active_from":"string (ISO date) | null. Link valid-from date (null = always).","[0].recipes[].active_to":"string (ISO date) | null. Link valid-to date (null = open-ended).","[0].recipes[].qty_used":"number. Quantity consumed per sale.","[0].channel_price":"array. Sales-channel prices ([] = none). Send back as update_product channel_price.","[0].channel_price[].channel_id":"int. Channel id (list_channels).","[0].channel_price[].channel_name":"string. Channel name.","[0].channel_price[].channel_price":"number. Channel price before VAT.","[0].channel_price[].channel_vat":"number. Tax part — tax out of scope; ignore.","[0].channel_price[].channel_actual_price":"number. Channel price VAT included.","[0].materials":"array. Direct raw-material (BOM) lines ([] = none). Send back as update_product materials (update wipes lines you omit).","[0].materials[].id":"int. product_materials row id. NEVER resend it in update_product materials — the server reads id before material_id.","[0].materials[].name":"string. Material name (from materials).","[0].materials[].product_id":"string. This product id — as a STRING (VARCHAR column).","[0].materials[].material_id":"number. Material id (list_materials / get_material).","[0].materials[].qty":"number. Quantity per product, in unit.","[0].materials[].customer_id":"int. Tenant id. Not needed.","[0].materials[].location_id":"int | null. Branch id stored on the line (first of the location_id sent at save). Informational.","[0].materials[].price":"number | null. Material unit price captured at save.","[0].materials[].unit":"string | null. Unit of qty.","[0].materials[].wastage":"string | null. Wastage as stored (VARCHAR, e.g. \"0\").","[0].materials[].materials_price":"number | null. Current material avg_price (send as avg_price on update).","[0].materials[].conversionrate":"number. Material conversion rate (default 1) (send as conversionrate on update).","[0].materials[].cost":"number | null. Current line cost = qty × materials_price / conversionrate.","[0].materials[].ingredientunit":"string | null. Material's ingredient unit name.","[0].location_prices":"array. Branches that sell the product and their prices — the real per-branch price list. Send back (edited) as update_product location_prices.","[0].location_prices[].location_id":"int. Branch id (list_locations).","[0].location_prices[].location_name":"string. Branch name.","[0].location_prices[].location_price":"number. Branch price column.","[0].location_prices[].location_actual_price":"number. Branch price VAT included (falls back to location_price) — what that branch charges.","[0].location_prices[].is_available":"boolean. false = hidden at this branch."},"example":[{"id":257508,"category_id":15231,"name":"Mocha","description":"Authentic Flavors","price":10,"stock":0,"type":"Espresso-Based Drinks","image":"https://dev1.syspos.ae/api/uploads/1780385109749-apple.png","customer_id":437,"location_id":[907,908],"is_addon":0,"addons":[],"bundle_items":[],"is_disabled":0,"discount_pct":0,"modifiers":[{"id":136528,"modifier_id":3687,"max_allowed":3,"min_allowed":0,"priority":4,"location_id":907,"customer_id":437,"selection_type":"multiple","auto_selected_modifier_item_ids":[],"mandatory_modifier_item_ids":[],"unavailable_modifier_item_ids":[],"modifier_items_order":[18005,18052,22145,22146]}],"is_archive":0,"barcode_type":"","barcode_value":"","is_open_price":0,"is_combo":0,"sync_deliverect":0,"sync_otter":0,"new_cost_date":null,"product_cost":null,"is_weightage_item":0,"unit_id":0,"actual_price":10,"old_price":null,"vat":0.48,"localized":null,"base_price":null,"menu_sort_order":0,"plu":0,"calories":null,"is_stock_product":0,"is_auto_apply":null,"combo_price":0,"localize_name":"Mocha","localize_description":"Coffee","label_id":50,"recipes":[{"id":16779,"name":"Test","yield_qty":0.8,"mode":"stockable","recipe_id":16779,"product_id":257508,"portion_qty":0.8,"unit":"Kilogram","active_from":null,"active_to":null,"qty_used":1}],"sku_no":"pd-437-257508","tax_id":16,"preparation_time":0,"product_recipe_total":50,"product_material_total":0,"is_overridden":0,"is_deleted":0,"category_sort_order":null,"image_path":"1780385109749-apple.png","web_image":"1780385109749-apple.png","channel_price":[],"materials":[],"location_prices":[{"location_id":907,"location_name":"Default location","location_price":10,"location_actual_price":10,"is_available":true}]}],"master_rules":["The dashboard edit page calls this with the id only and uses [0]: category from category_id (fallback: the category whose name is in type), label from label_id, one location checkbox per list_locations branch (ticked = has a location_prices row; price = location_actual_price), modifier groups from modifiers[] (the FIRST link per modifier_id wins; tenant-wide links (location_id null) sort first), channel_price, recipes and image_path.","Header and tabs: name, sku_no, \"Archived\" when is_archive = 1 (then Unarchive is offered), badges = number of location_prices, channel_price and modifiers entries.","To edit, copy this record into update_product: location_prices as { location_id, location_price: location_actual_price, is_available }, modifiers as { modifier_id, min_allowed, max_allowed, priority, selection_type, auto_selected_modifier_item_ids, mandatory_modifier_item_ids, unavailable_modifier_item_ids, modifier_items_order }, channel_price as is, recipes as { recipe_id, portion_qty, unit, qty_used }, image = image_path, type, tax_id unchanged, and materials as { material_id, qty, unit, wastage, avg_price: materials_price, conversionrate }.","location_prices[].is_available = false hides the product at that branch (list_selling_products drops it). The POS sold-out switch is a different flag (is_sold_out), read with list_sold_out_products and set with update_product_availability — it is not in this response.","Recipes apply to master tenants (dashboard Recipe tab, phantom recipes only, list_recipes). Direct BOM lines (materials) have no dashboard editor and are only carried over on save."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Product id (list_products data[].id)."},"loc":{"type":"string","description":"Branch id or comma-separated ids."}},"required":["id"]},"module":"products","safety":"read"},{"name":"create_product","description":"POST /api/products. Why: Add a new catalog product (master tenant). Check list_products (k=name) first — duplicate active names are rejected. Get category_id and its name (type) from list_categories, label_id from list_labels, branch ids for location_prices / location_id from list_locations, modifier_id from list_modifiers (item ids from list_modifier_items), channel ids from list_channels, recipe ids from list_recipes (phantom only). For add-ons use create_addon. The new product shows on list_selling_products only at branches listed in location_prices. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_product\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/products","why":"Add a new catalog product (master tenant). Check list_products (k=name) first — duplicate active names are rejected. Get category_id and its name (type) from list_categories, label_id from list_labels, branch ids for location_prices / location_id from list_locations, modifier_id from list_modifiers (item ids from list_modifier_items), channel ids from list_channels, recipe ids from list_recipes (phantom only). For add-ons use create_addon. The new product shows on list_selling_products only at branches listed in location_prices.","response":{"description":"200 → { success: true, message, productId }; the product itself is not returned — call get_product with productId. 400 → { err: \"Name can't be blank\" } when name is missing; 400 → { success: false, err, message } for a duplicate name (or name+label); 400 → { success: false, message, error } when a stockable recipe is linked. 500 → { success: false, message: \"Database error\", error } (e.g. an unknown body key, a foreign recipe id). Not transactional: a failure after the insert can leave a partially created product.","fields":{"success":"boolean. true on create; false on the duplicate / recipe / DB errors (absent on the missing-name 400).","message":"string. \"Product created successfully\" on success; a human-readable reason on error.","productId":"int. New product id → get_product / update_product / archive_product / delete_product.","err":"string. Error only: \"Name can't be blank\" or \"Product with name \\\"…\\\" already exists\".","error":"string. Error only: raw server/DB error text."},"example":{"success":true,"message":"Product created successfully","productId":257508},"master_rules":["Validate like the dashboard form before calling: name, actual_price and category_id required; at least one branch (location_prices row + location_id); label_id required when the tenant's product_unique_formula contains label_id; preparation_time 0–1440.","The dashboard sends: name, description, localize_name, localize_description, barcode_type, barcode_value, type (the category's name), stock 0, location_id, location_prices, is_addon false, is_weightage_item, unit_id, is_open_price, price, vat, actual_price, combo_price, discount_pct, preparation_time, modifiers, materials [], plu, sku_no, image, price_level, is_archive 0, category_id, label_id, recipes, tax_id. Send the same (tax_id: omit).","Price maths: the user types actual_price (VAT included); the dashboard sends price = round2(actual_price / (1 + VAT%/100)) and vat = round2(actual_price − price), VAT% from get_config_value name=\"vat\". The POS and sync_invoice charge actual_price.","Branches: each ticked branch → { location_id, location_price, location_actual_price (same number, default actual_price), is_available (switch, default true) }; with one branch the price is forced to actual_price. Send location_id as an array of the same ids.","Modifier groups: new groups default to min_allowed 0, max_allowed 1, priority = next number (1, 2, 3…), selection_type \"multiple\"; item toggles fill auto_selected / mandatory / unavailable ids and reordering fills modifier_items_order; the array is sent sorted by priority.","Channel prices go in price_level (create) and channel_price (update). Recipes: { recipe_id, portion_qty (the recipe's yield_qty), unit, qty_used (default 1), total_cost }.","On success the dashboard just returns to the list: confirm with get_product(productId) and list_selling_products at a listed branch; the auto SKU appears a moment later."]},"parameters":{"type":"object","properties":{"name":{"type":"string","description":"Required."},"description":{"type":"string","description":"Free-text description; also copied onto every branch row."},"actual_price":{"type":"number","description":"Master price, VAT INCLUDED — the \"Base Price\" the user types (required by the dashboard form)."},"price":{"type":"number","description":"Price EXCLUDING VAT."},"category_id":{"type":"number","description":"Category id (list_categories)."},"label_id":{"type":"number","description":"Label id (list_labels)."},"location_id":{"type":"array","items":{"type":"number","description":""},"description":"Branch id or array of branch ids (list_locations)."},"location_prices":{"type":"array","items":{"type":"object","properties":{"location_id":{"type":"number","description":"Branch id (list_locations)."},"location_price":{"type":"number","description":"Branch price, VAT included."},"location_actual_price":{"type":"number","description":"Create: wins over location_price when truthy."},"is_available":{"type":"string","description":"Truthy → 1 (sold at this branch), falsy → 0 (hidden at this branch)."}},"required":["location_id","location_price"],"description":"One branch row (products_locations)."},"description":"Branches that sell the product, with their price."},"modifiers":{"type":"array","items":{"type":"object","properties":{"modifier_id":{"type":"number","description":"Modifier group id (list_modifiers)."},"id":{"type":"number","description":"Existing link id (get_product → modifiers[].id)."},"min_allowed":{"type":"number","description":"Minimum options the customer must pick (0 = optional)."},"max_allowed":{"type":"number","description":"Maximum options allowed (0 = unlimited)."},"priority":{"type":"number","description":"Display order of the group on the product (lower first)."},"selection_type":{"type":"string","enum":["single","multiple"],"description":"\"single\" = pick one option; \"multiple\" = several."},"auto_selected_modifier_item_ids":{"type":"array","items":{"type":"number","description":""},"description":"Modifier item ids (list_modifier_items) pre-selected by default."},"mandatory_modifier_item_ids":{"type":"array","items":{"type":"number","description":""},"description":"Modifier item ids the customer cannot deselect."},"unavailable_modifier_item_ids":{"type":"array","items":{"type":"number","description":""},"description":"Modifier item ids hidden for this product."},"modifier_items_order":{"type":"array","items":{"type":"number","description":""},"description":"Item ids in display order."}},"required":["modifier_id"],"description":"Product↔modifier-group link."},"description":"Modifier groups attached to the product, written for each branch in location_id (or tenant-wide when none)."},"materials":{"type":"array","items":{"type":"object","properties":{"material_id":{"type":"number","description":"Material id (list_materials / get_material)."},"qty":{"type":"number","description":"Quantity of the material used per product, in `unit`."},"unit":{"type":"string","description":"Unit name for qty (e.g."},"wastage":{"type":"number","description":"Wastage value stored on the line."},"avg_price":{"type":"number","description":"Material cost per purchase unit (get_material avg_price)."},"conversionrate":{"type":"number","description":"Purchase-unit → ingredient-unit conversion (get_material conversionrate)."},"name":{"type":"string","description":"Material name copied onto the line (display only)."}},"required":["material_id"],"description":"One direct raw-material (BOM) line → product_materials."},"description":"Direct raw-material (BOM) lines; drive product_material_total / product_cost."},"recipes":{"type":"array","items":{"type":"object","properties":{"recipe_id":{"type":"number","description":"Recipe id (list_recipes / get_recipe)."},"portion_qty":{"type":"number","description":"Portion of the recipe used per product."},"qty_used":{"type":"number","description":"Quantity of the recipe consumed per sale."},"unit":{"type":"string","description":"Unit name for portion_qty."},"total_cost":{"type":"number","description":"Cost of this recipe portion; summed into product_recipe_total / product_cost."}},"required":["recipe_id"],"description":"One recipe link → product_recipes."},"description":"Recipe links (phantom recipes only); drive product_recipe_total / product_cost."},"sku_no":{"type":"string","description":"SKU; trimmed."},"plu":{"type":"number","description":"PLU / short code for the POS keypad (INT column)."},"image":{"type":"string","description":"Image path: an uploaded relative path (list_products image_path) or a URL on the SysPOS media host (normalised to a relative path)."},"is_addon":{"type":"string","description":"Truthy → 1 (add-on product), else 0."},"is_open_price":{"type":"string","description":"Truthy → 1 (cashier types the price at sale time)."},"is_weightage_item":{"type":"string","description":"Truthy → 1 (sold by weight; set unit_id)."},"unit_id":{"type":"number","description":"Weight unit id for weighted items."},"is_archive":{"type":"string","description":"Truthy → 1 (archived)."},"discount_pct":{"type":"number","description":"Product discount percent; missing, < 0 or > 100 → 0."},"combo_price":{"type":"number","description":"Price when sold inside a combo."},"localize_name":{"type":"string","description":"Second-language (e.g."},"localize_description":{"type":"string","description":"Second-language description."},"menu_sort_order":{"type":"number","description":"Sort position inside its category (ascending)."},"calories":{"type":"number","description":"Calories shown on menus."},"preparation_time":{"type":"number","description":"Preparation time (kitchen display hint)."},"barcode_type":{"type":"string","description":"Barcode symbology label: the dashboard sends \"EAN-13\" or \"QR Code\"."},"barcode_value":{"type":"string","description":"Barcode value scanned at the POS."},"is_stock_product":{"type":"number","description":"1 = tracked as a stock item, 0 = not."},"tax_id":{"type":"number","description":"Tax group id — tax groups are out of scope; omit on create."},"vat":{"type":"number","description":"VAT AMOUNT inside actual_price (not a %)."},"price_level":{"type":"array","items":{"type":"object","properties":{"channel_id":{"type":"number","description":"Channel id (list_channels)."},"channel_name":{"type":"string","description":"Channel name stored on the row (copy it from list_channels)."},"channel_price":{"type":"number","description":"Channel price before VAT."},"channel_vat":{"type":"number","description":"Tax part of the channel price — tax out of scope; send 0 or omit."},"channel_actual_price":{"type":"number","description":"Channel price VAT included (what the channel charges)."}},"required":["channel_id"],"description":"One sales-channel price (product_channel_price)."},"description":"Sales-channel prices (delivery apps etc.)."},"stock":{"type":"number","description":"Legacy opening-stock column (DECIMAL 10,2), not live inventory."},"type":{"type":"string","description":"Category NAME(s), comma-separated."}},"required":["name"]},"module":"products","safety":"write"},{"name":"update_product","description":"PUT /api/products/:id. Why: Edit one catalog product (name, price, category, branches + branch prices, modifier links, channel prices, BOM, recipes). NOT a patch: first call get_product and resend the whole record with your changes — omitted fields reset prices/image/tax to 0/empty, un-archive the product, wipe materials/recipes and rebuild or reprice branch rows. Same id sources as create_product (list_categories, list_labels, list_locations, list_modifiers, list_channels, list_recipes). Use archive_product to archive/restore, update_product_availability for the POS sold-out switch and delete_product to remove. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_product\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/products/:id","why":"Edit one catalog product (name, price, category, branches + branch prices, modifier links, channel prices, BOM, recipes). NOT a patch: first call get_product and resend the whole record with your changes — omitted fields reset prices/image/tax to 0/empty, un-archive the product, wipe materials/recipes and rebuild or reprice branch rows. Same id sources as create_product (list_categories, list_labels, list_locations, list_modifiers, list_channels, list_recipes). Use archive_product to archive/restore, update_product_availability for the POS sold-out switch and delete_product to remove.","response":{"description":"200 → { success: true, message, product } where product = { id: <path id as a string>, ...the request body as the server saw it } (customer_id added, sku_no trimmed, image/web_image normalised to relative paths). It is NOT re-read — call get_product to see stored values. 404 → { success: false, err: \"Product not found\", message }. 400 → { success: false, err, message } duplicate name. 400/500 → { error } (stockable recipe → 400; unknown body key / other DB errors → 500). Not transactional: an error part-way can leave the product half-updated.","fields":{"success":"boolean. true on update; false on the 404 / duplicate-name errors (absent on the 400/500 { error } errors).","message":"string. \"Product updated successfully\", or the error reason.","product":"object. Echo of the request body as the server saw it — NOT a DB read; call get_product for stored values.","product.id":"string. The path id, as a string.","product.customer_id":"int. Tenant id injected by the server. Not needed.","product.name":"Echo of the name you sent (absent when not sent).","product.description":"Echo of the description you sent (absent when not sent).","product.actual_price":"Echo of the actual_price you sent (absent when not sent).","product.price":"Echo of the price you sent (absent when not sent).","product.category_id":"Echo of the category_id you sent (absent when not sent).","product.label_id":"Echo of the label_id you sent (absent when not sent).","product.location_id":"Echo of the location_id you sent (absent when not sent).","product.location_prices":"Echo of the location_prices you sent (absent when not sent).","product.modifiers":"Echo of the modifiers you sent (absent when not sent).","product.replace_modifiers":"Echo of the replace_modifiers you sent (absent when not sent).","product.materials":"Echo of the materials you sent (absent when not sent).","product.recipes":"Echo of the recipes you sent (absent when not sent).","product.sku_no":"string. Echo of sku_no, trimmed.","product.plu":"Echo of the plu you sent (absent when not sent).","product.image":"string. Echo of image, normalised to a relative upload path.","product.is_addon":"Echo of the is_addon you sent (absent when not sent).","product.is_open_price":"Echo of the is_open_price you sent (absent when not sent).","product.is_weightage_item":"Echo of the is_weightage_item you sent (absent when not sent).","product.unit_id":"Echo of the unit_id you sent (absent when not sent).","product.is_archive":"Echo of the is_archive you sent (absent when not sent).","product.discount_pct":"Echo of the discount_pct you sent (absent when not sent).","product.combo_price":"Echo of the combo_price you sent (absent when not sent).","product.localize_name":"Echo of the localize_name you sent (absent when not sent).","product.localize_description":"Echo of the localize_description you sent (absent when not sent).","product.menu_sort_order":"Echo of the menu_sort_order you sent (absent when not sent).","product.calories":"Echo of the calories you sent (absent when not sent).","product.preparation_time":"Echo of the preparation_time you sent (absent when not sent).","product.barcode_type":"Echo of the barcode_type you sent (absent when not sent).","product.barcode_value":"Echo of the barcode_value you sent (absent when not sent).","product.is_stock_product":"Echo of the is_stock_product you sent (absent when not sent).","product.tax_id":"Echo of the tax_id you sent (absent when not sent).","product.vat":"Echo of the vat you sent (absent when not sent).","product.channel_price":"Echo of the channel_price you sent (absent when not sent).","product.modifiers_by_location":"Echo of the modifiers_by_location you sent (absent when not sent).","product.stock":"Echo of the stock you sent (absent when not sent).","product.type":"Echo of the type you sent (absent when not sent).","err":"string. Error only: \"Product not found\" (404) or \"Product with name \\\"…\\\" already exists\" (400).","error":"string. Error only (400 stockable recipe / 500 DB error, e.g. an unknown body key or a recipe of another tenant): raw error text."},"example":{"success":true,"message":"Product updated successfully","product":{"id":"257508","name":"Mocha","actual_price":12,"category_id":15231,"location_prices":[{"location_id":907,"location_price":12,"is_available":1}],"customer_id":437}},"master_rules":["The dashboard saves with the whole form (the create_product keys plus id), always with is_archive 0 — so a save restores an archived product; send is_archive 1 to keep it archived.","location_prices / location_id: the dashboard sends the ticked branches (edited) PLUS every existing branch not shown in the form, unchanged — the complete list. Do the same from get_product; a non-empty list deletes and re-inserts every branch row, which resets branch names and the POS sold-out flag (re-check list_sold_out_products, re-apply with update_product_availability).","Modifiers: when the editor is filtered to one branch the dashboard sends modifiers_by_location { \"<branch id>\": [groups visible there] }; otherwise modifiers, written for every branch in location_id (groups not listed are unlinked there) and each group's priority is copied to all branches.","channel_price: ticked channels plus channels hidden by branch scoping, unchanged; unticking every channel sends [] which clears nothing. The dashboard only updates channel_actual_price when the user types; send all three prices consistently.","Validation, price maths (price / vat from actual_price and get_config_value name=\"vat\") and branch/modifier defaults are the same as create_product.","recipes re-sent without total_cost reset product_cost to the materials part (the dashboard's plain resave does this); materials must be mapped (never get_product materials[].id)."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Product id (list_products / get_product)."},"name":{"type":"string","description":"Product name."},"description":{"type":"string","description":"Free-text description; also copied onto every branch row."},"actual_price":{"type":"number","description":"Master price, VAT INCLUDED (dashboard \"Base Price\", required by its form)."},"price":{"type":"number","description":"Price EXCLUDING VAT."},"category_id":{"type":"number","description":"Category id (list_categories)."},"label_id":{"type":"number","description":"Label id (list_labels)."},"location_id":{"type":"array","items":{"type":"number","description":""},"description":"Branch id or array of branch ids (list_locations)."},"location_prices":{"type":"array","items":{"type":"object","properties":{"location_id":{"type":"number","description":"Branch id (list_locations)."},"location_price":{"type":"number","description":"Branch price, VAT included."},"location_actual_price":{"type":"number","description":"Create: wins over location_price when truthy."},"is_available":{"type":"string","description":"Truthy → 1 (sold at this branch), falsy → 0 (hidden at this branch)."}},"required":["location_id","location_price"],"description":"One branch row (products_locations)."},"description":"FULL REPLACE of the product's branches: a non-empty array deletes all existing branch rows and inserts these (branch-specific names are reset to `name`)."},"modifiers":{"type":"array","items":{"type":"object","properties":{"modifier_id":{"type":"number","description":"Modifier group id (list_modifiers)."},"id":{"type":"number","description":"Existing link id (get_product → modifiers[].id)."},"min_allowed":{"type":"number","description":"Minimum options the customer must pick (0 = optional)."},"max_allowed":{"type":"number","description":"Maximum options allowed (0 = unlimited)."},"priority":{"type":"number","description":"Display order of the group on the product (lower first)."},"selection_type":{"type":"string","enum":["single","multiple"],"description":"\"single\" = pick one option; \"multiple\" = several."},"auto_selected_modifier_item_ids":{"type":"array","items":{"type":"number","description":""},"description":"Modifier item ids (list_modifier_items) pre-selected by default."},"mandatory_modifier_item_ids":{"type":"array","items":{"type":"number","description":""},"description":"Modifier item ids the customer cannot deselect."},"unavailable_modifier_item_ids":{"type":"array","items":{"type":"number","description":""},"description":"Modifier item ids hidden for this product."},"modifier_items_order":{"type":"array","items":{"type":"number","description":""},"description":"Item ids in display order."}},"required":["modifier_id"],"description":"Product↔modifier-group link."},"description":"Modifier groups attached to the product, written for each branch in location_id (or tenant-wide when none)."},"materials":{"type":"array","items":{"type":"object","properties":{"material_id":{"type":"number","description":"Material id (list_materials / get_material)."},"qty":{"type":"number","description":"Quantity of the material used per product, in `unit`."},"unit":{"type":"string","description":"Unit name for qty (e.g."},"wastage":{"type":"number","description":"Wastage value stored on the line."},"avg_price":{"type":"number","description":"Material cost per purchase unit (get_material avg_price)."},"conversionrate":{"type":"number","description":"Purchase-unit → ingredient-unit conversion (get_material conversionrate)."},"name":{"type":"string","description":"Material name copied onto the line (display only)."}},"required":["material_id"],"description":"One direct raw-material (BOM) line → product_materials."},"description":"Direct raw-material (BOM) lines; drive product_material_total / product_cost."},"recipes":{"type":"array","items":{"type":"object","properties":{"recipe_id":{"type":"number","description":"Recipe id (list_recipes / get_recipe)."},"portion_qty":{"type":"number","description":"Portion of the recipe used per product."},"qty_used":{"type":"number","description":"Quantity of the recipe consumed per sale."},"unit":{"type":"string","description":"Unit name for portion_qty."},"total_cost":{"type":"number","description":"Cost of this recipe portion; summed into product_recipe_total / product_cost."}},"required":["recipe_id"],"description":"One recipe link → product_recipes."},"description":"Recipe links (phantom recipes only); drive product_recipe_total / product_cost."},"sku_no":{"type":"string","description":"SKU; trimmed."},"plu":{"type":"number","description":"PLU / short code for the POS keypad (INT column)."},"image":{"type":"string","description":"Image path: an uploaded relative path (list_products image_path) or a URL on the SysPOS media host (normalised to a relative path)."},"is_addon":{"type":"string","description":"Truthy → 1 (add-on product), else 0."},"is_open_price":{"type":"string","description":"Truthy → 1 (cashier types the price at sale time)."},"is_weightage_item":{"type":"string","description":"Truthy → 1 (sold by weight; set unit_id)."},"unit_id":{"type":"number","description":"Weight unit id for weighted items."},"is_archive":{"type":"string","description":"Truthy → 1 (archived)."},"discount_pct":{"type":"number","description":"Product discount percent; missing, < 0 or > 100 → 0."},"combo_price":{"type":"number","description":"Price when sold inside a combo."},"localize_name":{"type":"string","description":"Second-language (e.g."},"localize_description":{"type":"string","description":"Second-language description."},"menu_sort_order":{"type":"number","description":"Sort position inside its category (ascending)."},"calories":{"type":"number","description":"Calories shown on menus."},"preparation_time":{"type":"number","description":"Preparation time (kitchen display hint)."},"barcode_type":{"type":"string","description":"Barcode symbology label: the dashboard sends \"EAN-13\" or \"QR Code\"."},"barcode_value":{"type":"string","description":"Barcode value scanned at the POS."},"is_stock_product":{"type":"number","description":"1 = tracked as a stock item, 0 = not."},"tax_id":{"type":"number","description":"Tax group id — tax groups are out of scope: never choose one."},"vat":{"type":"number","description":"VAT AMOUNT inside actual_price (not a %)."},"channel_price":{"type":"array","items":{"type":"object","properties":{"channel_id":{"type":"number","description":"Channel id (list_channels)."},"channel_name":{"type":"string","description":"Channel name stored on the row (copy it from list_channels)."},"channel_price":{"type":"number","description":"Channel price before VAT."},"channel_vat":{"type":"number","description":"Tax part of the channel price — tax out of scope; send 0 or omit."},"channel_actual_price":{"type":"number","description":"Channel price VAT included (what the channel charges)."}},"required":["channel_id"],"description":"One sales-channel price (product_channel_price)."},"description":"Sales-channel prices."},"modifiers_by_location":{"type":"string","description":"Per-branch modifier links: { \"<location_id>\": [modifier rows] }."},"stock":{"type":"number","description":"Legacy opening-stock column (DECIMAL 10,2), not live inventory."},"type":{"type":"string","description":"Category NAME(s), comma-separated."}},"required":["id"]},"module":"products","safety":"write"},{"name":"delete_product","description":"DELETE /api/products/:id. Why: Soft-delete a product: sets is_deleted = 1 so it disappears from list_products, list_selling_products and the menus at every branch; the row, branch rows, modifier links and history stay (there is no undelete tool). Prefer archive_product when it may come back, or location_prices[].is_available=false (update_product) to hide it at one branch. Get the id from list_products. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_product\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/products/:id","why":"Soft-delete a product: sets is_deleted = 1 so it disappears from list_products, list_selling_products and the menus at every branch; the row, branch rows, modifier links and history stay (there is no undelete tool). Prefer archive_product when it may come back, or location_prices[].is_available=false (update_product) to hide it at one branch. Get the id from list_products.","response":{"description":"Always { status: true, message: \"Product deleted successfully\" } (no product object), even if nothing matched the id — verify with list_products. A DB error has no handler and the request hangs until timeout.","fields":{"status":"boolean. Always true (not a real success check) — verify with list_products.","message":"string. Always \"Product deleted successfully\"."},"example":{"status":true,"message":"Product deleted successfully"},"master_rules":["Dashboard: row menu Delete (confirm \"Delete Product? This action cannot be undone.\"), bulk \"Delete selected\" (confirm, then one DELETE per id in sequence) and the edit page's Delete (confirm). The list page allows it only with specific branches selected (not All) — a UI guard only; the server deletes the product for every branch. Ask the user to confirm the same way.","After deleting, check list_products (default filters) no longer returns the id. There is no restore route, although the edit page says it can be restored. \"Delete all\" is another route with no MCP tool."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Product id (list_products data[].id)."}},"required":["id"]},"module":"products","safety":"destructive"},{"name":"archive_product","description":"PUT /api/products/archive_product/:id. Why: Hide a product from the active catalog and every branch menu without deleting it (is_archive = 1), or restore it with is_archive=\"0\". Archived products are listed with list_products is_archive=1 and dropped from list_selling_products. Use instead of delete_product when the item may come back; to hide it at one branch use update_product location_prices[].is_available=false, and for a temporary sell-out use update_product_availability. Note: update_product un-archives unless you send is_archive = 1. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"archive_product\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/products/archive_product/:id","why":"Hide a product from the active catalog and every branch menu without deleting it (is_archive = 1), or restore it with is_archive=\"0\". Archived products are listed with list_products is_archive=1 and dropped from list_selling_products. Use instead of delete_product when the item may come back; to hide it at one branch use update_product location_prices[].is_available=false, and for a temporary sell-out use update_product_availability. Note: update_product un-archives unless you send is_archive = 1.","response":{"description":"200 → { message, product }: product is the raw products row read back after the flag change, with image_path/web_image, location_id wrapped as [products.location_id] and placeholder empty arrays (addons, bundle_items, modifiers, channel_price, materials) + selectionLength 0 — not a full get_product. 404 → { message: \"Product not found after archiving.\" } when the id does not exist. 500 → { message: \"Failed to archive product\", error }.","fields":{"message":"string. Always \"Product archived successfully\" (also when restoring).","product":"object. The products row re-read after the change, plus placeholder arrays — not a full get_product.","product.id":"int. Product id. Pass as `id` to get_product / update_product / archive_product / delete_product.","product.category_id":"int | null. Primary category id (single INT column; see list_categories / get_category). Filter list_products with category_id.","product.name":"string. Product name.","product.description":"string | null. Free-text description.","product.price":"number. Stored `price` column (DECIMAL 10,4). Master dashboard saves it equal to actual_price; use actual_price.","product.stock":"number | null. Legacy opening-stock column. Not live inventory (see inventory/material tools); not needed.","product.type":"string | null. Category NAME(s), comma-separated; rebuilt from category_id on every create/update. The POS menu groups by it. Informational.","product.image":"string. Absolute image URL, or \"\" when the product has no image.","product.customer_id":"int. Tenant (merchant) id. Always your own tenant; not needed.","product.location_id":"array. [products.location_id] — a one-element array of the raw column, which is [null] for master products. NOT the branch list; use get_product.","product.is_addon":"0 | 1. 1 = add-on product (manage via the add-on tools / list_addons).","product.addons":"array. Always [] here (placeholder). Not needed.","product.bundle_items":"array. Always [] here. Not needed.","product.is_disabled":"0 | 1. Legacy hard-disable flag. Not needed.","product.discount_pct":"number | null. Product-level discount percent 0–100 (0/null = none).","product.modifiers":"array. Always [] here (placeholder, not the real links). Use get_product.","product.is_archive":"0 | 1. New value: 1 archived, 0 restored.","product.barcode_type":"string | null. Barcode symbology label as typed in the dashboard (\"\" / null = none).","product.barcode_value":"string | null. Barcode value scanned at the POS (\"\" / null = none).","product.is_open_price":"0 | 1. 1 = cashier types the price at sale time.","product.is_combo":"0 | 1. 1 = combo/deal product (managed by the deal tools).","product.sync_deliverect":"0 | 1. Deliverect integration sync flag. Not needed.","product.sync_otter":"0 | 1. Otter integration sync flag. Not needed.","product.new_cost_date":"string (ISO timestamp) | null. Last cost-change date (legacy, usually null). Not needed.","product.product_cost":"number | null. Cost = product_material_total + product_recipe_total, recomputed on every create/update.","product.is_weightage_item":"0 | 1 | null. 1 = sold by weight (price is per unit_id, e.g. per kg).","product.unit_id":"int | null. Weight unit id for weighted items (0/null = none).","product.actual_price":"number. MASTER price, VAT included — the default price every branch gets. Branch-specific prices are in get_product → location_prices[].","product.old_price":"number | null. Legacy previous price, usually null. Not needed.","product.vat":"number | null. Tax amount column — tax is out of scope; ignore.","product.localized":"string | null. Legacy localisation column, usually null. Not needed.","product.base_price":"number | null. Legacy base price, usually null. Not needed.","product.menu_sort_order":"int. Sort position inside its category on menus (ascending, default 0).","product.plu":"int | null. PLU / short code typed on the POS keypad (0/null = none).","product.calories":"int | null. Calories shown on menus (null = not set).","product.is_stock_product":"0 | 1 | null. 1 = tracked as a stock item. Informational.","product.is_auto_apply":"0 | 1 | null. Legacy auto-apply flag, usually null. Not needed.","product.combo_price":"number. Price when sold inside a combo (default 0).","product.localize_name":"string | null. Second-language (e.g. Arabic) name.","product.localize_description":"string | null. Second-language description.","product.label_id":"int | null. Label id (list_labels / get_label).","product.recipes":"null. Legacy TEXT column, always null. Real recipe links: get_product → recipes[] (list_products has has_recipe). Not needed.","product.sku_no":"string. SKU. Master tenants get an auto SKU \"prod-<customer_id>-<id>\" when created without one.","product.tax_id":"int | null. Tax group id — out of scope; omit.","product.preparation_time":"int | null. Preparation time as entered in the dashboard (kitchen display hint).","product.product_recipe_total":"number | null. Recipe cost part of product_cost.","product.product_material_total":"number | null. Direct-material (BOM) cost part of product_cost.","product.is_overridden":"0 | 1. 1 = at least one branch has a different price than actual_price or is unavailable (recomputed on save).","product.is_deleted":"0 | 1. 1 = soft-deleted by delete_product.","product.category_sort_order":"int | null. Legacy category ordering, usually null. Not needed.","product.image_path":"string. Relative upload path of the image (\"\" = none). Pass this (not the absolute URL) as `image` on update_product.","product.web_image":"string. Same value as image_path.","product.channel_price":"array. Always [] here. Use get_product.","product.materials":"array. Always [] here. Use get_product.","product.selectionLength":"int. Always 0 (dashboard UI helper). Not needed."},"example":{"message":"Product archived successfully","product":{"id":113573,"category_id":15481,"name":"Violet Whispers ","description":null,"price":0,"stock":0,"type":"Alternate Options","image":"https://dev1.syspos.ae/api/uploads/KOZ_3276 copy.jpg","customer_id":437,"location_id":[null],"is_addon":0,"addons":[],"bundle_items":[],"is_disabled":0,"discount_pct":0,"modifiers":[],"is_archive":1,"barcode_type":null,"barcode_value":null,"is_open_price":0,"is_combo":0,"sync_deliverect":0,"sync_otter":0,"new_cost_date":null,"product_cost":null,"is_weightage_item":0,"unit_id":0,"actual_price":0,"old_price":null,"vat":null,"localized":null,"base_price":null,"menu_sort_order":0,"plu":1236,"calories":null,"is_stock_product":0,"is_auto_apply":null,"combo_price":0,"localize_name":null,"localize_description":null,"label_id":null,"recipes":[],"sku_no":"prod-437-113573","tax_id":null,"preparation_time":0,"product_recipe_total":0,"product_material_total":0,"is_overridden":1,"is_deleted":0,"category_sort_order":null,"image_path":"KOZ_3276 copy.jpg","web_image":"KOZ_3276 copy.jpg","channel_price":[],"materials":[],"selectionLength":0}},"master_rules":["Dashboard edit page: \"Archive\" (confirm \"Archive product?\") sends is_archive=1; \"Unarchive\" (shown when is_archive = 1, confirm) sends is_archive=0; both return to the list. The list's Status filter \"Inactive\" = archived (list_products is_archive=1).","The list page's bulk Enable/Disable uses another route (POST /api/products/bulk-archive, no MCP tool): call archive_product once per id instead.","The returned product is a bare row with placeholder arrays — call get_product for the real record."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Product id (list_products; archived ones via is_archive=1)."},"is_archive":{"type":"string","enum":["0","1"],"description":"Exactly \"0\" → restore (is_archive = 0)."}},"required":["id"]},"module":"products","safety":"write"},{"name":"list_selling_categories","description":"GET /api/v2/categories. Why: Menu categories of a branch — what web-pos loads for its category rail. Use it to group list_selling_products into a menu. A product belongs to a category when product.type == category.name (web-pos's main link) or product.category_id == category.id. Not paginated. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_selling_categories\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/categories","why":"Menu categories of a branch — what web-pos loads for its category rail. Use it to group list_selling_products into a menu. A product belongs to a category when product.type == category.name (web-pos's main link) or product.category_id == category.id. Not paginated.","response":{"description":"A BARE JSON ARRAY of categories (no status/data envelope), not sorted. Only is_active = 1 categories linked to the branch (categories_locations) or created for it. Error: HTTP 500 { error: \"Database error\", detail }.","fields":{"[]":"array. Categories. [] = none.","[].id":"int. Category id. Matches product.category_id and combo slots product.categories[].category_id.","[].name":"string. Category name to show. ALSO the link key: products whose type equals this name (case-insensitive) belong here. Names are NOT unique (live data has two \"Burger\") — when a product has category_id, prefer matching by id.","[].image":"string. Full image URL, or \"\" = no image.","[].location_id":"int. Branch this row was returned for (= the loc you asked for).","[].menu_sort_order":"int. Sort key (ascending) for the menu. web-pos shows the API order instead; sort by this if you need a stable order.","[].is_deleted":"0 | 1. The server does NOT filter it — drop rows with is_deleted = 1.","[].is_active":"Always 1 (inactive categories are filtered out).","[].description":"string | null. NOT USED — category description.","[].color":"string | null. NOT USED — colour (null/\"\" in data; web-pos derives the colour from the name).","[].customer_id":"int. NOT USED — tenant id.","[].localize_name":"string | null. NOT USED — name in the second language.","[].localize_description":"string | null. NOT USED — description in the second language.","[].sku_no":"string. NOT USED — auto SKU cat-<customer>-<id>.","[].web_image":"string. NOT USED — relative image path \"api/uploads/…\" or \"\".","[].sync_menu":"0 | 1. NOT USED — include in online menu sync.","[].sync_otter":"0 | 1. NOT USED — Otter integration sync flag.","[].sync_deliverect":"0 | 1. NOT USED — Deliverect integration sync flag.","[].sync_bitebell":"0 | 1. NOT USED — Bitebell integration sync flag.","[].sync_grubtech":"0 | 1. NOT USED — Grubtech integration sync flag."},"master_rules":["Menu scoping (web-pos): brand → groups → category ids → categories → products. Products carry NO brand_id; a brand reaches its products only through its groups' category ids.","Allowed category ids = the selected group's categories (web-pos always selects a group when the brand has groups — default the FIRST group); if that group's list is empty, the union of all the brand's groups; if the brand has no groups, no scoping (everything).","Categories NOT in the allowed ids are hidden — including categories that belong to no group at all. If scoping would hide every category, web-pos shows all (fail-open).","Pipeline (web-pos rail): 1) drop the category named \"combo\" (combos are a separate Combos button, shown when any combo product exists); 2) keep only allowed category ids (fail-open); 3) keep only categories with at least one non-combo product at the branch — counted by category_id, else by lower-case type == name (fail-open). No sorting: web-pos shows them in id order.","Default selected category = the first remaining one. The selection is a category NAME; products are then matched as described in list_selling_products."],"example":[{"id":17557,"name":"Footwear","description":"Shoes","customer_id":437,"location_id":907,"color":null,"image":"https://dev1.syspos.ae/api/uploads/1767085134601-shoes.jpg","is_active":1,"sync_otter":0,"sync_deliverect":0,"sync_menu":1,"menu_sort_order":12,"sync_bitebell":1,"localize_name":null,"localize_description":null,"sku_no":"cat-437-17557","is_deleted":0,"sync_grubtech":0,"web_image":"api/uploads/1767085134601-shoes.jpg"}]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Optional."},"k":{"type":"string","description":"Optional."}},"required":[]},"module":"categories","safety":"read"},{"name":"list_categories","description":"GET /api/categories. Why: Dashboard catalog view of categories (the Products → Categories list), not the POS selling rail — use list_selling_categories to see what a branch actually sells. Use it to search by name, filter by branch / group / brand / active flag, get category ids for get_category → update_category / delete_category, and the id + name a product needs (product.category_id = id, product.type = name). Shows each category's branches (categories_locations) and groups (group_categories). Includes inactive categories unless is_active=1. Not paginated. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_categories\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/categories","why":"Dashboard catalog view of categories (the Products → Categories list), not the POS selling rail — use list_selling_categories to see what a branch actually sells. Use it to search by name, filter by branch / group / brand / active flag, get category ids for get_category → update_category / delete_category, and the id + name a product needs (product.category_id = id, product.type = name). Shows each category's branches (categories_locations) and groups (group_categories). Includes inactive categories unless is_active=1. Not paginated.","response":{"description":"HTTP 200 with a BARE JSON ARRAY of categories ([] when nothing matches). No envelope, no pagination; is_deleted is neither returned nor filtered (delete_category hard-deletes). Without loc: one row per category, sorted by name, location_id = int[]. With loc: one row per (category, branch), unsorted, location_id = int. Error: HTTP 500 { error: \"Failed to fetch categories\", detail } (MCP returns it as isError { error, status, data }). If the dashboard route rejects the token (401) MCP retries GET /api/v2/categories (selling rows: active only, no groups / total_categories, location_id int).","fields":{"[]":"array. One entry per category (per category + branch when loc is sent).","[].id":"int. Category id → get_category / update_category / delete_category; product.category_id; list_products category_id filter; combo slots product.categories[].category_id.","[].name":"string. Category name. Also the name link: product.type, add-on offer lists (list_selling_addons type) and KDS stations (list_kds_orders c) hold this name. NOT unique (live dev1 has two \"Burger\") — match by id when you can.","[].description":"string | null. Free-text description; null in most rows.","[].customer_id":"int. Tenant id (= session). Not needed.","[].sku_no":"string. Auto code \"cat-<customer_id>-<id>\" (generated asynchronously after create/update); \"\" if not generated yet. Dashboard shows it under the name (\"—\" when empty).","[].location_id":"int[] (no loc) | int (with loc). Branches that sell the category (categories_locations); [] = no branch → sold nowhere. With loc: the branch this row was returned for. Dashboard shows \"<n> Locations\" (a single int counts as 1).","[].color":"string | null. Hex tile colour; null/\"\" in most rows. Cosmetic.","[].image":"string. Image file name RELATIVE to the uploads folder (e.g. \"1767085134601-shoes.jpg\"), \"\" = none. Not a URL (get_category returns the full URL). Resend it as image in update_category to keep the image.","[].web_image":"string. \"api/uploads/<image>\" — prefix the API host for a URL; \"\" = none.","[].is_active":"0 | 1. 1 = active (sold), 0 = inactive/archived. Both come back unless you filter. Dashboard: Active/Inactive toggle per row.","[].sync_otter":"0 | 1. Pushed to Otter.","[].sync_deliverect":"0 | 1. Pushed to Deliverect.","[].sync_menu":"0 | 1. Included in the online-menu / storefront sync.","[].sync_bitebell":"0 | 1. Pushed to Bitebell.","[].sync_grubtech":"0 | 1. Pushed to Grubtech (dashboard Grubtech sync screen toggles it).","[].menu_sort_order":"int. Menu sort key (ascending). Dashboard shows it in the \"Sort\" column and sorts by it, then by name.","[].total_categories":"int. Number of rows in THIS response (same on every row; counts per-branch duplicates when loc is sent).","[].groups":"array. Groups (group_categories joined to this tenant's groups) containing this category; [] = in no group → hidden on the POS for brands that have groups. group_categories.is_active is not checked. Dashboard shows \"<n> Groups\".","[].groups[].id":"int. Group id → get_group / update_group; list_categories group_id filter.","[].groups[].group_name":"string. Group name (\"\" if null).","[].groups[].categories_count":"int. Always 1 (group_categories stores one category per row) — not the group's size. Count with list_categories group_id=… instead."},"master_rules":["Dashboard Categories page calls this with: loc only when the Location filter is set (csv of ticked branches), k from the search box, group_id from the Group filter, is_active 1/0 from the Active filter (All = omitted), plus _page/_limit that the server ignores. It dedupes rows by id when several branches are selected (merging location_id), sorts by menu_sort_order then name, and pages 10 rows in the browser. To reproduce it: omit loc or pass ONE branch, dedupe by id, sort by menu_sort_order then name.","Each dashboard row shows: image, name, sku_no (\"—\" if empty), menu_sort_order, \"<n> Locations\" (location_id length), \"<n> Groups\" (groups length) and an Activate/Deactivate toggle from is_active; header stats Total / Active / Inactive are counted from the returned rows.","Send is_active as the number 1 or 0. Several dashboard screens (deals, add-ons, inventory report) send is_active=true, which the server reads as 0 and returns only inactive categories — don't copy that.","Use brand_id only together with loc; without loc it always returns [].","What a branch really sells is list_selling_categories (active AND linked to that branch in categories_locations); use this tool to manage, that one to show a menu.","A product belongs to a category two ways: product.category_id (this id; csv when in several) and product.type (this NAME; comma-joined). web-pos fills the category grid by type == name first, so both must match — set both in create_product / update_product.","Brand → group → category: groups hold category ids (group_categories, one row per category; edit with update_group, full replace — get_group first). On the POS, brands that have groups show only categories in their groups (list_selling_brands); a category in no group is hidden there.","Add-ons are offered by category NAME (list_selling_addons type = csv of names) and KDS stations filter by category NAME (list_kds_orders c): use the exact current name."],"caveats":"Server bugs: group_id together with loc also lets through categories whose own categories.location_id is in loc (normally NULL on master tenants, so rare); brand_id without loc → []; groups[].categories_count is always 1.","example":[{"id":17557,"name":"Footwear","description":"Shoes","customer_id":437,"sku_no":"cat-437-17557","location_id":[909,908,922,910,907,1131],"color":null,"image":"1767085134601-shoes.jpg","web_image":"api/uploads/1767085134601-shoes.jpg","is_active":1,"sync_otter":0,"sync_deliverect":0,"sync_menu":1,"sync_bitebell":1,"sync_grubtech":0,"menu_sort_order":12,"total_categories":39,"groups":[{"id":1465,"group_name":"Accessories","categories_count":1}]}]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id or csv of ids (list_locations)."},"k":{"type":"string","description":"Case-insensitive partial match on the category name (LIKE %k%, parameterised; % and _ act as wildcards)."},"group_id":{"type":"number","description":"Group id (list_groups): only categories in that group (group_categories, inactive links included)."},"brand_id":{"type":"number","description":"Brand id (list_brands / list_selling_brands): only categories in a group linked to that brand (brands_groups_relation → group_categories)."},"is_active":{"type":"number","description":"1 = only active, 0 = only inactive."},"is_archive":{"type":"number","description":"Inverse alias: 0 = only active, 1 = only inactive."}},"required":[]},"module":"categories","safety":"read"},{"name":"get_category","description":"GET /api/categories/:id. Why: Full dashboard record of ONE category (the Edit Category page): every column, its branches, its groups, and the active products filed under it by category_id (linked_products) — list_categories has no product list. Use it (a) as the base body for update_category (image / is_active / sync_* / menu_sort_order reset if omitted), (b) to check linked_products_count and groups before delete_category. For what a branch sells use list_selling_categories / list_selling_products instead. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_category\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/categories/:id","why":"Full dashboard record of ONE category (the Edit Category page): every column, its branches, its groups, and the active products filed under it by category_id (linked_products) — list_categories has no product list. Use it (a) as the base body for update_category (image / is_active / sync_* / menu_sort_order reset if omitted), (b) to check linked_products_count and groups before delete_category. For what a branch sells use list_selling_categories / list_selling_products instead.","response":{"description":"HTTP 200 with a one-element array [category], or [] when the id is not in this tenant. The category is every categories column, with location_id replaced by its branch ids, image turned into a full URL, and groups / linked_products added. No active/deleted filter on the category itself. Error: HTTP 500 { error: \"Failed to fetch category\", detail }. If the dashboard route rejects the token (401) MCP retries GET /api/v2/categories/:id, which returns the raw row only (location_id null, no groups / linked_products).","fields":{"[]":"array. Exactly one category, or [] = not found.","[0].id":"int. Category id.","[0].name":"string. Category name = the value products carry in product.type (and add-on offer lists / KDS stations use). Dashboard page title.","[0].description":"string | null. Description (form field \"Description\").","[0].customer_id":"int. Tenant id. Not needed.","[0].location_id":"int[]. Branch ids from categories_locations ([] = no branch). Send back as location_id in update_category. Dashboard: Locations checklist.","[0].color":"string | null. Hex colour, cosmetic (no dashboard field).","[0].image":"string. FULL image URL (\"https://<host>/api/uploads/…\"), \"\" = none. Send back as-is to update_category (it strips to the file name). Dashboard: Category Image box.","[0].web_image":"string. \"api/uploads/<file>\" relative path, \"\" = none.","[0].is_active":"0 | 1. 1 = active. Dashboard: header Active/Inactive chip and \"Active Status\" switch.","[0].sync_otter":"0 | 1. Otter sync flag (no dashboard control).","[0].sync_deliverect":"0 | 1. Deliverect sync flag (no dashboard control).","[0].sync_menu":"0 | 1. Online-menu sync flag (\"Sync Menu\" switch).","[0].sync_bitebell":"0 | 1. Bitebell sync flag (no dashboard control).","[0].sync_grubtech":"0 | 1. Grubtech sync flag (\"Sync Grubtech\" switch).","[0].menu_sort_order":"int. Menu sort key, ascending (\"Sort Order\" field).","[0].localize_name":"string | null. Secondary-language name (\"Local Name (Arabic)\").","[0].localize_description":"string | null. Secondary-language description (\"Local Description (Arabic)\").","[0].sku_no":"string | null. Auto code \"cat-<customer_id>-<id>\"; dashboard header \"SKU: …\" and a read-only SKU field.","[0].is_deleted":"0 | 1. Legacy flag; delete_category hard-deletes, so normally 0. Not needed.","[0].groups":"array. Groups containing this category (group_categories joined to this tenant's groups), sorted by group_name, one per group; [] = in no group → hidden on the POS for brands that have groups. Dashboard: \"Linked Groups\" panel with an open-group link.","[0].groups[].id":"int. Group id → get_group / update_group (edit membership there).","[0].groups[].group_name":"string. Group name (\"\" if null).","[0].groups[].categories_count":"int. Always 1 (one category per group_categories row); the dashboard wrongly shows it as \"1 category\". Not the group's size.","[0].linked_products":"array. Products with this id in product.category_id (single or csv), excluding add-ons, combos, disabled, archived and deleted ones; sorted by category_sort_order (> 0 first) then name. Products linked only by product.type are NOT listed. Dashboard: \"Linked Products\" table.","[0].linked_products[].id":"int. Product id → get_product / update_product (dashboard shows \"ID: <id>\" when sku_no is empty).","[0].linked_products[].name":"string. Product name.","[0].linked_products[].sku_no":"string. Product SKU, \"\" if none.","[0].linked_products[].price":"number. actual_price if non-zero, else price — master catalog price, not a branch price.","[0].linked_products[].is_archive":"0. Always 0 (archived products are excluded).","[0].linked_products[].category_sort_order":"int. Position inside this category (0 = unsorted, listed after sorted ones). Dashboard drag-reorder writes it through a route MCP does not expose.","[0].linked_products[].image":"string. Product image file name relative to uploads, \"\" = none.","[0].linked_products[].web_image":"string. \"api/uploads/<image>\", \"\" = none.","[0].linked_products_count":"int. Length of linked_products. > 0 blocks delete_category; the dashboard shows a \"Products Linked\" warning."},"master_rules":["The dashboard Edit Category page loads this record and shows: name, localize_name, description, localize_description, menu_sort_order, read-only sku_no, the Locations checklist (location_id), Active / Sync Menu / Sync Grubtech switches, the image, Linked Groups (groups[]), a \"Products Linked\" warning when linked_products_count > 0 and the Linked Products table (image, name, sku_no, price, position).","To edit: take [0], change only the fields you mean to, and send it to update_category with location_id (the array), image (as returned), is_active, every sync_* flag and menu_sort_order — omitted ones reset.","To delete: linked_products_count must be 0 (move products with update_product or deactivate instead), and remove the category from each groups[] entry with update_group first — delete_category leaves group links behind.","linked_products misses products linked only by name: also look for products / add-ons whose type contains this name (list_products, list_addons) before renaming or deleting.","Brand → group → category: groups hold category ids (group_categories, one row per category; edit with update_group, full replace — get_group first). On the POS, brands that have groups show only categories in their groups (list_selling_brands); a category in no group is hidden there.","Add-ons are offered by category NAME (list_selling_addons type = csv of names) and KDS stations filter by category NAME (list_kds_orders c): use the exact current name."],"example":[{"id":17557,"name":"Footwear","description":"Shoes","customer_id":437,"location_id":[909,908,922,910,907,1131],"color":null,"image":"https://dev1.syspos.ae/api/uploads/1767085134601-shoes.jpg","is_active":1,"sync_otter":0,"sync_deliverect":0,"sync_menu":1,"menu_sort_order":12,"sync_bitebell":1,"localize_name":null,"localize_description":null,"sku_no":"cat-437-17557","is_deleted":0,"sync_grubtech":0,"web_image":"api/uploads/1767085134601-shoes.jpg","groups":[{"id":1465,"group_name":"Accessories","categories_count":1}],"linked_products":[{"id":266406,"name":"Flip-Flop","sku_no":"prod-437-266406","price":10,"is_archive":0,"category_sort_order":0,"image":"","web_image":""}],"linked_products_count":2}]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Category id (list_categories [].id)."}},"required":["id"]},"module":"categories","safety":"read"},{"name":"create_category","description":"POST /api/categories. Why: Create a new catalog category (dashboard Add Category) linked to branches. Returns the new id, which you then use as product.category_id (and its name as product.type) in create_product / update_product, and add to a group with update_group so brands with groups show it on the POS. It sells only at the branches in location_id. Check list_categories k=<name> first — duplicate names are not rejected. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_category\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/categories","why":"Create a new catalog category (dashboard Add Category) linked to branches. Returns the new id, which you then use as product.category_id (and its name as product.type) in create_product / update_product, and add to a group with update_group so brands with groups show it on the POS. It sells only at the branches in location_id. Check list_categories k=<name> first — duplicate names are not rejected.","response":{"description":"HTTP 200 { success, message, category_id } — no category object; call get_category for it. Inserts one categories row (location_id NULL, is_active 1, sku_no filled asynchronously) and one categories_locations row per branch. Does not touch groups or products. Errors (MCP returns isError { error: \"SYSPOS API <status> …\", status, data: <body below> }): 400 { error: \"Category name is required\" }; 400 { error: \"Category name cannot contain commas. Please use a single name without commas.\" }; 500 { success: false, error: <DB message> } (or { success: false, error, code } with the error's own status).","fields":{"success":"boolean. true on success; false on 500.","message":"string. \"Category created successfully\".","category_id":"int. New categories.id → get_category, product.category_id, update_group categories.","error":"string. Only on errors: reason text (see description).","code":"string. Only on errors that carry their own status code: machine code."},"master_rules":["The dashboard Add Category form (Products → Categories → Add Category) sends: name (required), localize_name, description, localize_description, menu_sort_order (default 0), location_id as a csv of the ticked Locations (at least one required), image = file name from a separate upload, sync_menu / sync_grubtech 0|1 (default off), color \"\", plus is_active, sku_no and customer_id, which the server ignores. Send the same fields; location_id as an array of branch ids.","A new category is always active, whatever is_active says; create it, then update_category is_active 0 if it must start inactive.","sync_menu defaults to 0 here (the dashboard switch starts off); send 1 if the category must appear in online menus.","After creating: add it to the right group(s) with update_group (full replace — get_group first), then file products under it with update_product (category_id = new id AND type = name). Confirm with list_selling_categories loc=<branch>.","Image upload is not available in MCP; reuse an existing file name or leave image empty."],"example":{"success":true,"message":"Category created successfully","category_id":19250}},"parameters":{"type":"object","properties":{"name":{"type":"string","description":"Category name (varchar 300), trimmed."},"location_id":{"type":"array","items":{"type":"number","description":"Branch (location) id from list_locations."},"description":"Branches that sell this category — one categories_locations row per id."},"image":{"type":"string","description":"Image file name relative to /api/uploads (e.g."},"description":{"type":"string","description":"Free-text description (categories.description, varchar 300)."},"color":{"type":"string","description":"Tile colour, hex like \"#FF8800\" (varchar 45)."},"menu_sort_order":{"type":"number","description":"Menu sort key, ascending (lower first)."},"localize_name":{"type":"string","description":"Name in the secondary language (dashboard \"Local Name (Arabic)\", right-to-left), varchar 255."},"localize_description":{"type":"string","description":"Description in the secondary language (dashboard \"Local Description (Arabic)\"), varchar 255."},"sync_menu":{"type":"number","description":"0 | 1."},"sync_grubtech":{"type":"number","description":"0 | 1."},"sync_otter":{"type":"number","description":"0 | 1."},"sync_deliverect":{"type":"number","description":"0 | 1."},"sync_bitebell":{"type":"number","description":"0 | 1."}},"required":["name","location_id"]},"module":"categories","safety":"write"},{"name":"update_category","description":"PUT /api/categories/:id. Why: Edit one catalog category (dashboard Edit Category / row Activate-Deactivate / Grubtech sync screen): rename (product.type follows for products filed under this id), change branches, activate/deactivate, image, colour, sort order, channel sync flags. Deactivating (is_active 0) is the safe alternative to delete_category when products still use it. Call get_category first and send the whole record back — image, is_active, sync_* and menu_sort_order RESET when omitted. Group membership is edited with update_group, not here. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_category\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/categories/:id","why":"Edit one catalog category (dashboard Edit Category / row Activate-Deactivate / Grubtech sync screen): rename (product.type follows for products filed under this id), change branches, activate/deactivate, image, colour, sort order, channel sync flags. Deactivating (is_active 0) is the safe alternative to delete_category when products still use it. Call get_category first and send the whole record back — image, is_active, sync_* and menu_sort_order RESET when omitted. Group membership is edited with update_group, not here.","response":{"description":"HTTP 200 with the request body echoed back plus id — NOT a fresh read (defaults applied to omitted fields are not shown; call get_category to see the result). Every other key you send comes back too. Side effects: UPDATE categories (location_id set NULL), product.type sync when name is sent, group_categories.is_active and deal_categories.is_active set to is_active, categories_locations replaced when location_id parses to ≥1 id, sku_no regenerated. Errors (MCP returns isError { error: \"SYSPOS API <status> …\", status, data: <body> }): 400 { error: \"Category name cannot contain commas. Please use a single name without commas.\" }; 404 { error: \"Category not found or access denied.\" }; 500 { error: \"Update failed\", details: <DB message> }.","fields":{"id":"string. The path id as in the URL (e.g. \"17557\").","name":"string. Echo of body.name, trimmed (only if sent).","description":"string. Echo (only if sent).","location_id":"array | string. Echo of body.location_id as sent — not the resulting branch list.","color":"string. Echo (only if sent).","image":"string. ALWAYS present: the stored file name (URL cut after \"uploads/\"), or \"\" when you sent none — meaning the image was cleared.","is_active":"number. Echo (only if sent; omitted → stored as 1).","sync_otter":"number. Echo (only if sent; omitted → stored as 0).","sync_deliverect":"number. Echo (only if sent; omitted → stored as 0).","sync_menu":"number. Echo (only if sent; omitted → stored as 0).","sync_bitebell":"number. Echo (only if sent; omitted → stored as 0).","sync_grubtech":"number. Echo (only if sent; omitted → stored as 0).","menu_sort_order":"number. Echo (only if sent; omitted → stored as 0).","localize_name":"string. Echo (only if sent).","localize_description":"string. Echo (only if sent).","error":"string. Only on 400 / 404 / 500.","details":"string. Only on 500: DB error text."},"master_rules":["Do it like the dashboard: it always submits the whole record — the Edit page sends the get_category record with the form values (location_id as csv of ticked branches, image as the current URL / new file name / \"\" after removal, sync_menu / sync_grubtech / is_active as 0|1); the row Activate/Deactivate toggle and the Grubtech sync screen resend the list row with only is_active / sync_grubtech changed. So: get_category → change the fields you mean to → send name, description, color, localize_name, localize_description, location_id (array), image, is_active, sync_menu, sync_grubtech, sync_otter, sync_deliverect, sync_bitebell, menu_sort_order.","location_id replaces the branch set: to add a branch send current ids + the new one; to remove one send the rest. Never take location_id from a list_categories call with several loc ids (the dashboard's row toggle does, and unlinks every unselected branch).","Renaming: products whose category_id contains this id get the old name replaced by the new one in product.type; single-category products whose type did not contain the old name get type = new name (this includes add-ons whose own category_id is this id, whose offer list is then overwritten). Products and add-ons that reference the category only by name are NOT updated: after a rename, find them (list_products / list_addons with the old name in type) and update_product their type; add-on offer lists (list_selling_addons type) and KDS station filters (list_kds_orders c) must use the new name.","Sending name at all (even unchanged) runs that sync; omit name when you are not renaming.","is_active 0 hides the category from list_selling_categories (the POS rail) and marks its group and deal links inactive; its products stay on sale under other categories they belong to. The edit form's Active / Sync switches use separate single-field routes MCP does not expose — use this tool with the full record.","Group membership is not changed here — use update_group."],"example":{"name":"Footwear","location_id":[909,908,922,910,907,1131],"image":"1767085134601-shoes.jpg","is_active":1,"sync_menu":1,"sync_bitebell":1,"sync_grubtech":0,"sync_otter":0,"sync_deliverect":0,"menu_sort_order":12,"id":"17557"}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Category id (list_categories / get_category)."},"name":{"type":"string","description":"New name, trimmed; 400 if it contains a comma."},"location_id":{"type":"array","items":{"type":"number","description":"Branch (location) id from list_locations."},"description":"Full list of branches that sell this category."},"image":{"type":"string","description":"Image."},"is_active":{"type":"number","description":"1 = active, 0 = inactive/archived (dropped from list_selling_categories, the POS rail)."},"description":{"type":"string","description":"Free-text description (categories.description, varchar 300)."},"color":{"type":"string","description":"Tile colour, hex like \"#FF8800\" (varchar 45)."},"menu_sort_order":{"type":"number","description":"Menu sort key, ascending (lower first)."},"localize_name":{"type":"string","description":"Name in the secondary language (dashboard \"Local Name (Arabic)\", right-to-left), varchar 255."},"localize_description":{"type":"string","description":"Description in the secondary language (dashboard \"Local Description (Arabic)\"), varchar 255."},"sync_menu":{"type":"number","description":"0 | 1."},"sync_grubtech":{"type":"number","description":"0 | 1."},"sync_otter":{"type":"number","description":"0 | 1."},"sync_deliverect":{"type":"number","description":"0 | 1."},"sync_bitebell":{"type":"number","description":"0 | 1."}},"required":["id","location_id"]},"module":"categories","safety":"write"},{"name":"delete_category","description":"DELETE /api/categories/:id. Why: Permanently delete an unused catalog category (dashboard trash icon / Edit page Delete). Refused while any active product has it in product.category_id (check get_category linked_products_count first; move those products with update_product, or set is_active 0 via update_category instead). Irreversible. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_category\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/categories/:id","why":"Permanently delete an unused catalog category (dashboard trash icon / Edit page Delete). Refused while any active product has it in product.category_id (check get_category linked_products_count first; move those products with update_product, or set is_active 0 via update_category instead). Irreversible.","response":{"description":"ALWAYS HTTP 200 (also on refusal or failure) with { status, message } — check status, not the HTTP code. Refused when the id is not in this tenant or when linked_products_count > 0 (archived / disabled / add-on / combo products and products linked only by product.type do NOT block it and are left pointing at a deleted id). On success: DELETE the categories row and its deal_categories rows. Bugs: group_categories is deleted by its own row id (= the category id), so the category's real group links stay and an unrelated group link may be removed; categories_locations rows are not deleted.","fields":{"status":"boolean. true = deleted; false = refused or failed (see message).","message":"string. \"category deleted successfully.\" | \"Category not found.\" | \"You can't delete this category. It's being used. \" | \"Failed to delete category.\" | \"Unauthorized.\""},"master_rules":["Dashboard: trash icon on a Categories row or Delete on the Edit page → confirm \"Are you sure you want to delete …? This action cannot be undone.\" → this call. The list shows message as a success or error toast by status; the Edit page always says \"Category deleted.\" even when refused — always read status yourself.","Before deleting: get_category → linked_products_count must be 0 and remove the category from each groups[] entry with update_group (delete leaves group links behind); also check list_products / list_addons for products whose type still holds this name and update them.","Prefer update_category is_active 0 when unsure — it is reversible and hides the category from the POS."],"example":{"status":true,"message":"category deleted successfully."}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Category id (list_categories / get_category)."}},"required":["id"]},"module":"categories","safety":"destructive"},{"name":"list_selling_modifiers","description":"POST /api/v2/modifiers. Why: All option groups (Size, Milk, Toppings…) available at a branch, each with its items and item prices — what web-pos loads once, then looks up by product.modifiers[].modifier_id from list_selling_products. Read-only despite POST; no body. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_selling_modifiers\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/v2/modifiers","why":"All option groups (Size, Milk, Toppings…) available at a branch, each with its items and item prices — what web-pos loads once, then looks up by product.modifiers[].modifier_id from list_selling_products. Read-only despite POST; no body.","response":{"description":"{ status, message, data: groups[] } sorted by group name. Groups = groups assigned to the branch (modifiers_locations) + groups created for the branch. Each group has items[] (all items of the group, not filtered by branch, unordered). Option price = items[].actual_price (VAT incl.). Min/max rules come from the PRODUCT link (list_selling_products modifiers[]), not from the group. Error: HTTP 500 { status:false, message, error }.","fields":{"status":"boolean. true = success (also when empty).","message":"string. \"Record Found\" | \"No record found\".","data[].id":"int. Group id = product.modifiers[].modifier_id.","data[].name":"string. Group name to show (e.g. \"Size\").","data[].items":"array. The group's options. [] = empty group (hide it). Not sorted — sort by priority if you want order.","data[].items[].id":"int. Option id — send it in the line's modifier selection.","data[].items[].name":"string. Option name (e.g. \"Large\").","data[].items[].actual_price":"number. THE option price per unit, VAT included. Line adds actual_price × option quantity. 0 = free option.","data[].items[].modifier_id":"int. Parent group id (= data[].id).","data[].items[].priority":"int. Option order (ascending, default 0).","data[].items[].instructions":"array. Preset kitchen notes for this option [{ id, instruction, priority }], sorted. [] = none. web-pos does not offer them yet.","data[].selection_allowed":"int. NOT USED — group-level default max; the product link's max_allowed is the rule.","data[].selection_min_allowed":"int. NOT USED — group-level default min (can be -1 in data); the product link's min_allowed is the rule.","data[].description":"string | null. NOT USED — group description.","data[].priority":"int. NOT USED by web-pos — group-level display order (default 100); the product link's priority is per product.","data[].items[].description":"string | null. NOT USED — option description.","data[].items[].price":"number. NOT USED — legacy (equals actual_price in all data). Use actual_price.","data[].items[].vat":"number | null. NOT USED — VAT amount (0/null in data; tax out of scope).","data[].items[].image":"string. NOT USED by web-pos — option image URL or \"\".","data[].items[].web_image":"string. NOT USED — relative image path or \"\".","data[].items[].tax_id":"int | null. NOT USED — tax-group id (tax out of scope).","data[].items[].tax_name":"string | null. NOT USED — tax-group name (tax out of scope).","data[].items[].tax_value":"number | null. NOT USED — tax-group % (tax out of scope)."},"caveats":"Soft-deleted groups/items are not filtered by the server — only use groups referenced by a product's modifiers[]. A product may reference a group that is not in this list (another branch's group): skip it. An item linked to SEVERAL groups (modifier_item_assignments) is nested only under its primary modifier_id here, so it appears in one group only — this is exactly what web-pos shows (list_modifier_items?modifier_id=… lists every linked item, e.g. group 3687 has 5 there vs 4 here).","example":{"status":true,"message":"Record Found","data":[{"id":3687,"name":"Buntie","description":"","selection_allowed":1,"selection_min_allowed":-1,"priority":2,"items":[{"id":18005,"name":"Chocolate buntie","description":"","price":5,"modifier_id":3687,"actual_price":5,"vat":0,"priority":1,"image":"","tax_id":16,"tax_name":"Food Card Tax","tax_value":5,"instructions":[{"id":39,"instruction":"Modifier 123","priority":0}],"web_image":""},{"id":18052,"name":"Rainbow","description":"","price":5,"modifier_id":3687,"actual_price":5,"vat":0,"priority":2,"image":"","tax_id":null,"tax_name":null,"tax_value":null,"instructions":[{"id":40,"instruction":"Modifier","priority":0},{"id":140,"instruction":"Modifier","priority":0},{"id":141,"instruction":"Modifier are extra","priority":1}],"web_image":""},{"id":22145,"name":"Rainbow Buntie","description":"","price":5,"modifier_id":3687,"actual_price":5,"vat":0,"priority":3,"image":"","tax_id":null,"tax_name":null,"tax_value":null,"instructions":[{"id":42,"instruction":"Modifier","priority":0},{"id":142,"instruction":"Modifier","priority":0},{"id":143,"instruction":"Modifier are extra","priority":1}],"web_image":""},{"id":22146,"name":"Plain Buntie","description":"","price":5,"modifier_id":3687,"actual_price":5,"vat":0,"priority":4,"image":"","tax_id":null,"tax_name":null,"tax_value":null,"instructions":[{"id":43,"instruction":"Modifier","priority":0},{"id":144,"instruction":"Modifier","priority":0},{"id":145,"instruction":"Modifier are extra","priority":1}],"web_image":""}]}]}},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Optional."}},"required":[]},"module":"modifiers","safety":"read"},{"name":"list_modifiers","description":"GET /api/modifiers. Why: Catalog management (dashboard Products › Modifiers list): every modifier GROUP of the tenant with its branch list and group defaults — no items, no product links. Use it to find a group id for get/update/delete_modifier, list_modifier_items modifier_id=, create/update_modifier_item modifier_ids and update_product modifiers[].modifier_id. To SELL (a group with its options and prices at a branch) use list_selling_modifiers; items of a group: list_modifier_items modifier_id=<id>; which products use a group: get_product / list_selling_products modifiers[]. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_modifiers\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/modifiers","why":"Catalog management (dashboard Products › Modifiers list): every modifier GROUP of the tenant with its branch list and group defaults — no items, no product links. Use it to find a group id for get/update/delete_modifier, list_modifier_items modifier_id=, create/update_modifier_item modifier_ids and update_product modifiers[].modifier_id. To SELL (a group with its options and prices at a branch) use list_selling_modifiers; items of a group: list_modifier_items modifier_id=<id>; which products use a group: get_product / list_selling_products modifiers[].","response":{"description":"Bare JSON array of group rows (every modifiers column), sorted by name ascending; [] when nothing matches. location_id is rebuilt from modifiers_locations. No items, no product links. The server does not filter on is_deleted. Error: HTTP 500 {error:\"Failed to fetch modifiers\"}.","fields":{"[].id":"int. Modifier group id. Use in get/update/delete_modifier, as modifier_id in list_modifier_items, in create/update_modifier_item modifier_ids[], and as modifiers[].modifier_id in update_product. Same id as list_selling_modifiers data[].id and list_selling_products modifiers[].modifier_id.","[].name":"string. Group name (e.g. \"Milk\"); the dashboard title-cases it. Max 100 chars.","[].description":"string | null. Free text; null on older rows, \"\" when saved without one. The dashboard shows \"—\" when empty. Not used by the POS.","[].location_id":"int | int[]. Branches that carry the group (modifiers_locations): an int when exactly one, an array when several, [] when none; legacy groups without branch rows return their own modifiers.location_id. Normalise with Array.isArray; send back as an ARRAY to update_modifier. Branch names: list_locations.","[].customer_id":"int. Tenant id (the session tenant). Not needed.","[].selection_allowed":"int. Group-level default max picks (0 = unlimited). Informational: the POS uses the product link max_allowed (update_product modifiers[] / list_selling_products modifiers[]); the dashboard does not copy this value when linking.","[].selection_min_allowed":"int. Group-level default min picks (0 = optional; -1 on some legacy rows). Informational: the product link min_allowed is the rule.","[].priority":"int. Group order (ascending). The dashboard list re-sorts by name and shows 0 as \"N/A\"; its drag/sort rewrites 1..n. Per-product order is product_modifiers.priority (update_product).","[].is_deleted":"int (0|1). Soft-delete column; nothing sets or filters it (delete_modifier is a hard delete), always 0. Not needed.","[].localize_name":"string | null. Translated (e.g. Arabic) name; null when not set.","[].localize_description":"string | null. Translated description; null when not set. Not needed.","[].sku_no":"string | null. \"mod-<customer_id>-<id>\", generated after create for master tenants; null on older rows. Read-only (shown disabled on the dashboard edit form); not writable.","[].is_mandatory":"int (0|1). Legacy column, never written by these tools and not used by the POS (required-ness = product link min_allowed). Not needed.","error":"string. Only on HTTP 500: \"Failed to fetch modifiers\"."},"master_rules":["The dashboard Modifiers page sends loc (default the user's own branch; the multi-select sends a csv, \"All Locations\" sends every branch id), k (search box) and brand_id (Brand select), plus _page/_limit that the server ignores — it pages 5/10/25 rows client-side. Reproduce a view with loc and k; skip brand_id (504).","Each dashboard row shows name (title-cased), description (\"—\" when empty), Selection Allowed = selection_allowed, Min Allowed = selection_min_allowed and Priority (0 shows \"N/A\"), sorted A–Z by name.","Reorder groups like the dashboard's drag/sort: update_modifier for every group with priority 1..n — but resend the full record (the dashboard sends only name, description, selection_allowed, selection_min_allowed, priority, location_id and so nulls localize_name / localize_description).","The dashboard edit screen takes the group from this list (same loc), not from get_modifier; the product form loads this list with loc = the branch being edited to offer groups to link, and the modifier-item form uses it (loc) for its Modifiers multi-select.","A group sells at a branch only when a product there links it (update_product modifiers[]) — check list_selling_products modifiers[] and list_selling_modifiers for that branch, not this list."],"example":[{"id":4865,"name":"Basic Accessory","description":"","location_id":[922,907,908,909,910,1131],"customer_id":437,"selection_allowed":0,"selection_min_allowed":0,"priority":0,"is_deleted":0,"localize_name":null,"localize_description":null,"sku_no":null,"is_mandatory":0}]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id or csv (e.g."},"k":{"type":"string","description":"Substring search on the group name (LIKE %k%; dashboard: search box)."},"brand_id":{"type":"number","description":"Only groups linked (product_modifiers) to products in categories of this brand (list_brands id; dashboard: \"Brand\" select)."}},"required":[]},"module":"modifiers","safety":"read"},{"name":"get_modifier","description":"GET /api/modifiers/:id. Why: Catalog management: load ONE modifier group (name, group min/max defaults, priority, translations, branch list) before update_modifier — update is a full overwrite, so read first and resend every field. No items (list_modifier_items modifier_id=<id>) and no linked products (get_product modifiers[]). For selling use list_selling_modifiers. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_modifier\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/modifiers/:id","why":"Catalog management: load ONE modifier group (name, group min/max defaults, priority, translations, branch list) before update_modifier — update is a full overwrite, so read first and resend every field. No items (list_modifier_items modifier_id=<id>) and no linked products (get_product modifiers[]). For selling use list_selling_modifiers.","response":{"description":"A ONE-element JSON array [group] with every modifiers column; location_id = its modifiers_locations branch ids (int when one, array when several, [] when none; legacy groups keep their own int). Not found → HTTP 200 [] (no 404). Error: HTTP 500 {error:\"Failed to fetch modifier\", detail}.","fields":{"[0].id":"int. Modifier group id. Use in get/update/delete_modifier, as modifier_id in list_modifier_items, in create/update_modifier_item modifier_ids[], and as modifiers[].modifier_id in update_product. Same id as list_selling_modifiers data[].id and list_selling_products modifiers[].modifier_id.","[0].name":"string. Group name (e.g. \"Milk\"); the dashboard title-cases it. Max 100 chars.","[0].description":"string | null. Free text; null on older rows, \"\" when saved without one. The dashboard shows \"—\" when empty. Not used by the POS.","[0].location_id":"int | int[]. Branches that carry the group (modifiers_locations): an int when exactly one, an array when several, [] when none; legacy groups without branch rows return their own modifiers.location_id. Normalise with Array.isArray; send back as an ARRAY to update_modifier. Branch names: list_locations.","[0].customer_id":"int. Tenant id (the session tenant). Not needed.","[0].selection_allowed":"int. Group-level default max picks (0 = unlimited). Informational: the POS uses the product link max_allowed (update_product modifiers[] / list_selling_products modifiers[]); the dashboard does not copy this value when linking.","[0].selection_min_allowed":"int. Group-level default min picks (0 = optional; -1 on some legacy rows). Informational: the product link min_allowed is the rule.","[0].priority":"int. Group order (ascending). The dashboard list re-sorts by name and shows 0 as \"N/A\"; its drag/sort rewrites 1..n. Per-product order is product_modifiers.priority (update_product).","[0].is_deleted":"int (0|1). Soft-delete column; nothing sets or filters it (delete_modifier is a hard delete), always 0. Not needed.","[0].localize_name":"string | null. Translated (e.g. Arabic) name; null when not set.","[0].localize_description":"string | null. Translated description; null when not set. Not needed.","[0].sku_no":"string | null. \"mod-<customer_id>-<id>\", generated after create for master tenants; null on older rows. Read-only (shown disabled on the dashboard edit form); not writable.","[0].is_mandatory":"int (0|1). Legacy column, never written by these tools and not used by the POS (required-ness = product link min_allowed). Not needed.","error":"string. Only on HTTP 500: \"Failed to fetch modifier\".","detail":"string. Only on HTTP 500: the database error message."},"master_rules":["The dashboard never calls this route (it edits from list_modifiers rows); use it before update_modifier and send back name, description, selection_allowed, selection_min_allowed, priority, localize_name, localize_description and location_id as an array.","Treat [] as not found. The group's options: list_modifier_items modifier_id=<id> (includes items linked through a second group); the POS view of it: list_selling_modifiers."],"example":[{"id":3687,"name":"Buntie","description":"","location_id":[907,908,909,910,922,1131],"customer_id":437,"selection_allowed":1,"selection_min_allowed":-1,"priority":2,"is_deleted":0,"localize_name":null,"localize_description":null,"sku_no":"mod-437-3687","is_mandatory":0}]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Modifier group id (list_modifiers [].id)."}},"required":["id"]},"module":"modifiers","safety":"read"},{"name":"create_modifier","description":"POST /api/modifiers. Why: Create a modifier GROUP (e.g. \"Milk\") at one or more branches (dashboard Modifiers › Add). Next: add its options with create_modifier_item (modifier_ids [new id], location_ids = the same branches), then link it to products with update_product modifiers[] ({modifier_id, min_allowed, max_allowed, priority, selection_type, default/mandatory item ids}). It reaches the POS (list_selling_modifiers / list_selling_products modifiers[]) only once a product links it. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_modifier\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/modifiers","why":"Create a modifier GROUP (e.g. \"Milk\") at one or more branches (dashboard Modifiers › Add). Next: add its options with create_modifier_item (modifier_ids [new id], location_ids = the same branches), then link it to products with update_product modifiers[] ({modifier_id, min_allowed, max_allowed, priority, selection_type, default/mandatory item ids}). It reaches the POS (list_selling_modifiers / list_selling_products modifiers[]) only once a product links it.","response":{"description":"HTTP 200 with the stored values plus the new id. location_id is null for master tenants (branches go to modifiers_locations — confirm with get_modifier). sku_no \"mod-<customer_id>-<id>\" is generated right after and is not in the response. Errors: 400 {error:\"Modifier name is required.\"}; 400 {error:\"Location IDs are required for master users.\"}; 500 {error:\"Failed to create modifier\", detail}.","fields":{"id":"int. New modifier group id — use it in create_modifier_item modifier_ids and update_product modifiers[].modifier_id.","name":"string. Stored name (trimmed, first letter upper-cased).","description":"string. Stored description (\"\" when omitted).","location_id":"null. Always null for master tenants — read the branch list back with get_modifier.","customer_id":"int. Tenant id. Not needed.","selection_allowed":"int. Stored group default max (0 when omitted).","selection_min_allowed":"int. Stored group default min (0 when omitted).","priority":"int. Stored order (0 when omitted).","localize_name":"string | null. Stored translated name.","localize_description":"string | null. Stored translated description.","error":"string. Only on 400/500: \"Modifier name is required.\" | \"Location IDs are required for master users.\" | \"Failed to create modifier\".","detail":"string. Only on 500: the database error message."},"master_rules":["The dashboard Add Modifier form has Name (required), Description, Local Name, Local Description, Selection Allowed (default 0), Minimum Selection Allowed (default 0), Priority (default 0) and Locations (tick at least one; master can tick several or all). Validation is only name required + one location; numbers are not range-checked — keep them ≥ 0 and min ≤ max.","Group min/max are only defaults for display: when the dashboard links a group to a product it starts at min 0, max 1, selection_type multiple, priority = next slot. Set the real per-product rule with update_product modifiers[].","Create the options next (create_modifier_item with modifier_ids [id] and location_ids = this group's branches) — a group without items shows no options at the POS.","The dashboard toasts success without checking the response; you must check for error in the reply."],"example":{"name":"Milk","description":"","location_id":null,"customer_id":437,"selection_allowed":1,"selection_min_allowed":0,"priority":0,"localize_name":null,"localize_description":null,"id":5701}},"parameters":{"type":"object","properties":{"name":{"type":"string","description":"Group name, e.g."},"description":{"type":"string","description":"Free-text description (max 100 chars; dashboard: \"Description\")."},"location_id":{"type":"array","items":{"type":"number","description":"Branch id (list_locations)."},"description":"Branch ids that carry the group, as an ARRAY, e.g."},"selection_allowed":{"type":"number","description":"Group default max picks (dashboard: \"Selection Allowed\", default 0; 0 = unlimited)."},"selection_min_allowed":{"type":"number","description":"Group default min picks (dashboard: \"Minimum Selection Allowed\", default 0; 0 = optional)."},"priority":{"type":"number","description":"Group order, ascending (dashboard: \"Priority\", default 0; list drag/sort writes 1..n)."},"localize_name":{"type":"string","description":"Translated (e.g."},"localize_description":{"type":"string","description":"Translated description (dashboard: \"Local Description\")."}},"required":["name","location_id"]},"module":"modifiers","safety":"write"},{"name":"update_modifier","description":"PUT /api/modifiers/:id. Why: Rename a modifier group, change its group defaults / order / translations, or change which branches carry it (dashboard Modifiers › Edit). FULL overwrite: get_modifier first and resend every field; location_id replaces the branch list. Does not touch the group's items (update_modifier_item) or its product links and per-product min/max (update_product modifiers[]). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_modifier\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/modifiers/:id","why":"Rename a modifier group, change its group defaults / order / translations, or change which branches carry it (dashboard Modifiers › Edit). FULL overwrite: get_modifier first and resend every field; location_id replaces the branch list. Does not touch the group's items (update_modifier_item) or its product links and per-product min/max (update_product modifiers[]).","response":{"description":"HTTP 200 { success:true, id, ...your body echoed } — the echo is your input, not the stored row (re-read with get_modifier). An unknown id still returns success. modifiers.location_id is set to NULL and modifiers_locations is deleted and re-inserted from location_id. Error: HTTP 500 {success:false, message:\"Update failed\", error} (e.g. name omitted).","fields":{"success":"boolean. true when the SQL ran (also for a non-existent id); false on HTTP 500.","id":"string. The :id from the path, as a string.","<each body field you sent>":"Echoed back with the same name and raw value you sent. Not a confirmation of stored values — re-read with get_modifier.","message":"string. Only on HTTP 500: \"Update failed\".","error":"string. Only on HTTP 500: the database error message."},"master_rules":["The dashboard edit form resends the whole row (plus id, customer_id, sku_no, is_deleted, is_mandatory, which are ignored) with location_id as a csv; do the same from get_modifier with location_id as an array, changing only what the user asked.","The edit screen also lists the group's items (list_modifier_items modifier_id=<id>, columns name, description, Price = actual_price, Priority) with drag / priority box / A–Z sort; each change calls update_modifier_item with only name, description, actual_price, vat, localize_*, tax_id, priority and modifier (primary group) — which drops the item's other group links. To reorder items use update_modifier_item with the full record (modifier_ids + location_ids from get_modifier_item).","The edit screen's \"Linked products\" panel (priority / min / max per product) uses /modifiers/:id/linked-products, which has no MCP tool — change product links with update_product modifiers[] instead.","Removing a branch from location_id leaves the group's product links at that branch in place; unlink it per product with update_product."],"example":{"success":true,"id":"4417","name":"Milk","description":"","location_id":[907,908],"selection_allowed":1,"selection_min_allowed":0,"priority":0,"localize_name":"حليب","localize_description":""}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Modifier group id to update (list_modifiers [].id)."},"name":{"type":"string","description":"Group name, e.g."},"description":{"type":"string","description":"Free-text description (max 100 chars; dashboard: \"Description\")."},"location_id":{"type":"array","items":{"type":"number","description":"Branch id (list_locations)."},"description":"Branch ids that carry the group, as an ARRAY, e.g."},"selection_allowed":{"type":"number","description":"Group default max picks (dashboard: \"Selection Allowed\", default 0; 0 = unlimited)."},"selection_min_allowed":{"type":"number","description":"Group default min picks (dashboard: \"Minimum Selection Allowed\", default 0; 0 = optional)."},"priority":{"type":"number","description":"Group order, ascending (dashboard: \"Priority\", default 0; list drag/sort writes 1..n)."},"localize_name":{"type":"string","description":"Translated (e.g."},"localize_description":{"type":"string","description":"Translated description (dashboard: \"Local Description\")."}},"required":["id","name","location_id"]},"module":"modifiers","safety":"write"},{"name":"delete_modifier","description":"DELETE /api/modifiers/:id. Why: Permanently remove a modifier group (dashboard trash icon). Also deletes its product links (product_modifiers) at every branch. Does NOT delete its items (they stay as orphans — delete or re-group them first with delete_modifier_item / update_modifier_item), its modifiers_locations rows or product_modifier_item_settings. Past orders (sync_invoice lines) keep their own copy. Confirm with the user first. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_modifier\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/modifiers/:id","why":"Permanently remove a modifier group (dashboard trash icon). Also deletes its product links (product_modifiers) at every branch. Does NOT delete its items (they stay as orphans — delete or re-group them first with delete_modifier_item / update_modifier_item), its modifiers_locations rows or product_modifier_item_settings. Past orders (sync_invoice lines) keep their own copy. Confirm with the user first.","response":{"description":"Always HTTP 200 { status:true, message:\"modifier deleted successfully\" } — also for an unknown id or a failed delete (no usage or tenant check). Verify with get_modifier (returns [] once gone).","fields":{"status":"boolean. Always true (not a reliable success signal).","message":"string. Always \"modifier deleted successfully\"."},"master_rules":["The dashboard asks for confirmation, then shows response.message and refetches the list; always confirm with the user first — there is no undo.","Before deleting, list_modifier_items modifier_id=<id>: delete those items or move them to another group (update_modifier_item modifier_ids) — items whose primary group is deleted keep a dangling modifier_id (modifier_name null) and drop out of list_selling_modifiers.","Every product loses the group at every branch; re-check affected products with get_product and relink a replacement with update_product if needed."],"example":{"status":true,"message":"modifier deleted successfully"}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Modifier group id to delete (list_modifiers [].id)."}},"required":["id"]},"module":"modifiers","safety":"destructive"},{"name":"list_modifier_items","description":"GET /api/modifierItems. Why: Catalog management (dashboard Products › Modifier Items list): options flat, with their groups, branches and price — to find an item id for get/update/delete_modifier_item or for update_product default / mandatory / unavailable item ids. Filter one group with modifier_id (also returns items linked to it as a second group). To SELL (groups with nested options + kitchen instructions at a branch) use list_selling_modifiers, which nests each option only under its primary modifier_id. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_modifier_items\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/modifierItems","why":"Catalog management (dashboard Products › Modifier Items list): options flat, with their groups, branches and price — to find an item id for get/update/delete_modifier_item or for update_product default / mandatory / unavailable item ids. Filter one group with modifier_id (also returns items linked to it as a second group). To SELL (groups with nested options + kitchen instructions at a branch) use list_selling_modifiers, which nests each option only under its primary modifier_id.","response":{"description":"Bare JSON array of option rows: every modifier_Items column plus modifier_name (all groups), mapped_location_ids, tax_name/tax_value, parsed modifier_ids and image URLs. Sorted by priority, then id; [] when nothing matches. Not filtered on is_deleted; orphan options (group deleted) come back with modifier_name null. Error: HTTP 500 {error:\"Database error\"}.","fields":{"[].id":"int. Option id. Use in get/update/delete_modifier_item and in update_product modifiers[] auto_selected / mandatory / unavailable_modifier_item_ids and modifier_items_order. Same id as list_selling_modifiers data[].items[].id and sync_invoice line modifier rows.","[].name":"string. Option name (e.g. \"Oat milk\"); the dashboard title-cases it.","[].description":"string | null. Free text; mostly null or \"\". Not used by the POS.","[].price":"number. LEGACY price column (= actual_price when saved through these tools). Do not use — use actual_price.","[].customer_id":"int. Tenant id. Not needed.","[].location_id":"int | int[]. Branches that carry the option (modifierItems_locations; int when one, array when several, order not stable; legacy column value when there are no branch rows; [] when neither). Normalise with Array.isArray; send back as location_ids.","[].modifier_id":"int | null. PRIMARY group id — the group list_selling_modifiers nests this option under. Usually modifier_ids[0], but can differ on old rows. null = no group.","[].actual_price":"number | null. THE option price per unit, VAT included (dashboard column \"Actual Price\"; the POS adds actual_price × qty). 0 = free; null only if never set (treat as 0).","[].old_price":"null. Legacy column. Not needed.","[].vat":"number | null. Stored VAT amount, unused (0/null). Not needed.","[].base_price":"null. Legacy column. Not needed.","[].localize_name":"string | null. Translated option name.","[].localize_description":"string | null. Translated description. Not needed.","[].sku_no":"string | null. \"moditem-<customer_id>-<id>\" on newer rows, null/\"\" on older ones. Read-only.","[].tax_id":"int | null. Id of the joined tax row (null when none or when that tax no longer exists) — tax is out of scope. Resend get_modifier_item tax_id (not this one) on update.","[].priority":"int. Order inside the group (ascending). The list is sorted by priority, then id.","[].is_deleted":"int (0|1). Soft-delete column; always 0 (delete is hard). Not needed.","[].image":"string. Full image URL (<backend>/api/uploads/<path>) or \"\" when none. Display only — never send it back.","[].web_image":"string. \"api/uploads/<path>\" or \"\". The image body field is <path> (strip \"api/uploads/\").","[].modifier_name":"string | null. Names of ALL linked groups joined with \", \" (alphabetical; dashboard column \"Modifiers\"); falls back to the primary group's name; null when the group was deleted (orphan item).","[].mapped_location_ids":"string | null. Raw csv of the branch ids (same data as location_id). Not needed.","[].tax_name":"string | null. Joined tax name — out of scope. Not needed.","[].tax_value":"number | null. Joined tax rate — out of scope. Not needed.","[].modifier_ids":"int[]. ALL groups the option belongs to (modifier_item_assignments, ascending); [modifier_id] when there are no assignment rows. Send back as modifier_ids on update.","error":"string. Only on HTTP 500: \"Database error\"."},"master_rules":["The dashboard Modifier Items page sends loc (default the user's branch; several → csv), k (search box), modifier_id (\"Modifiers\" select, options from list_modifiers loc=<same loc>) and brand_id (\"Brand\" select, clears modifier_id), plus _page/_limit that the server ignores — it pages client-side.","Each dashboard row shows the image thumbnail (image, else web_image), name (title-cased), description, Actual Price = actual_price (0 when null), Modifiers = modifier_name, and actions Connect Recipes / Edit / Delete; image upload/remove and recipes use their own routes (no MCP tool).","The product form calls this with modifier_id only (no loc) for each linked group, sorted by priority then id (or by that product's modifier_items_order), to tick Default / Mandatory / Unavailable per product — set those with update_product modifiers[] auto_selected / mandatory / unavailable_modifier_item_ids (see update_product); list_selling_products shows the defaults as auto_selected_modifier_items.","Per product: marking an option mandatory also makes it a default, marking it unavailable removes it from default and mandatory, and selection_type single keeps one default — keep the same invariants when you build update_product modifiers[].","An option in several groups (modifier_ids length > 1) is listed here under each group filter, but the POS (list_selling_modifiers) shows it only under modifier_id."],"example":[{"id":18005,"name":"Chocolate buntie","description":"","price":5,"customer_id":437,"location_id":[1131,922,910,909,908,907],"modifier_id":3687,"actual_price":5,"old_price":null,"vat":0,"base_price":null,"localize_name":"Chocolate bunties","localize_description":null,"sku_no":"moditem-437-18005","tax_id":16,"priority":1,"is_deleted":0,"image":"","modifier_name":"Buntie","mapped_location_ids":"1131,922,910,909,908,907","tax_name":"Food Card Tax","tax_value":5,"modifier_ids":[3687],"web_image":""}]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id or csv (e.g."},"k":{"type":"string","description":"Substring search on the option name (LIKE %k%, escaped; dashboard: search box)."},"modifier_id":{"type":"number","description":"Only options of this group (list_modifiers [].id; dashboard: \"Modifiers\" select): primary modifier_id = it OR linked via modifier_item_assignments."},"brand_id":{"type":"number","description":"Only options whose PRIMARY group is linked to a product in a category of this brand (list_brands id; dashboard: \"Brand\" select, which clears modifier_id)."}},"required":[]},"module":"modifier_items","safety":"read"},{"name":"get_modifier_item","description":"GET /api/modifierItems/:id. Why: Load ONE option before update_modifier_item (dashboard Modifier Items › Edit loads exactly this) — update resets price / priority / tax and replaces groups and branches, so read first and resend actual_price, priority, modifier_ids, location_ids and tax_id. No kitchen instructions (see list_selling_modifiers items[].instructions) and no recipes. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_modifier_item\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/modifierItems/:id","why":"Load ONE option before update_modifier_item (dashboard Modifier Items › Edit loads exactly this) — update resets price / priority / tax and replaces groups and branches, so read first and resend actual_price, priority, modifier_ids, location_ids and tax_id. No kitchen instructions (see list_selling_modifiers items[].instructions) and no recipes.","response":{"description":"A ONE-element JSON array [item] with every modifier_Items column plus modifier_name (primary group), modifier, modifier_ids and image URLs; no mapped_location_ids / tax_name / tax_value. Not found → HTTP 404 {message:\"Modifier item not found\"}. Error: HTTP 500 {error:\"Internal server error\"}.","fields":{"[0].id":"int. Option id. Use in get/update/delete_modifier_item and in update_product modifiers[] auto_selected / mandatory / unavailable_modifier_item_ids and modifier_items_order. Same id as list_selling_modifiers data[].items[].id and sync_invoice line modifier rows.","[0].name":"string. Option name (e.g. \"Oat milk\"); the dashboard title-cases it.","[0].description":"string | null. Free text; mostly null or \"\". Not used by the POS.","[0].price":"number. LEGACY price column (= actual_price when saved through these tools). Do not use — use actual_price.","[0].customer_id":"int. Tenant id. Not needed.","[0].location_id":"int | int[]. Branches that carry the option (modifierItems_locations ids: int when one, array when several, [] when none). Send back as location_ids.","[0].modifier_id":"int | null. PRIMARY group id — the group list_selling_modifiers nests this option under. Usually modifier_ids[0], but can differ on old rows. null = no group.","[0].actual_price":"number | null. THE option price per unit, VAT included (dashboard column \"Actual Price\"; the POS adds actual_price × qty). 0 = free; null only if never set (treat as 0).","[0].old_price":"null. Legacy column. Not needed.","[0].vat":"number | null. Stored VAT amount, unused (0/null). Not needed.","[0].base_price":"null. Legacy column. Not needed.","[0].localize_name":"string | null. Translated option name.","[0].localize_description":"string | null. Translated description. Not needed.","[0].sku_no":"string | null. \"moditem-<customer_id>-<id>\" on newer rows, null/\"\" on older ones. Read-only.","[0].tax_id":"int | null. Raw modifier_Items.tax_id — tax is out of scope, but resend it unchanged on update_modifier_item (omitting nulls it).","[0].priority":"int. Order inside the group (ascending). ","[0].is_deleted":"int (0|1). Soft-delete column; always 0 (delete is hard). Not needed.","[0].image":"string. Full image URL (<backend>/api/uploads/<path>) or \"\" when none. Display only — never send it back.","[0].web_image":"string. \"api/uploads/<path>\" or \"\". The image body field is <path> (strip \"api/uploads/\").","[0].modifier_name":"string | null. Name of the PRIMARY group only; null when that group was deleted.","[0].modifier":"string. Primary group id as a string (String(modifier_ids[0])), \"\" when none. Legacy form field. Not needed.","[0].modifier_ids":"int[]. ALL groups the option belongs to (modifier_item_assignments, ascending); [modifier_id] when there are no assignment rows. Send back as modifier_ids on update.","message":"string. Only on HTTP 404: \"Modifier item not found\".","error":"string. Only on HTTP 500: \"Internal server error\"."},"master_rules":["The dashboard edit form fills from this row: location_id → ticked Locations, modifier_ids (else [modifier_id]) → Modifiers multi-select, web_image/image → thumbnail, tax_id → Tax Group / Tax selects (out of scope), sku_no read-only. Kitchen instructions load from /modifierItems/:id/instructions (no MCP tool).","To update, copy name, description, actual_price, priority, modifier_ids, location_id (as location_ids), localize_name, localize_description and tax_id from here; leave image out unless you are changing it."],"example":[{"id":18005,"name":"Chocolate buntie","description":"","price":5,"customer_id":437,"location_id":[907,908,909,910,922,1131],"modifier_id":3687,"actual_price":5,"old_price":null,"vat":0,"base_price":null,"localize_name":"Chocolate bunties","localize_description":null,"sku_no":"moditem-437-18005","tax_id":16,"priority":1,"is_deleted":0,"image":"","modifier_name":"Buntie","modifier":"3687","modifier_ids":[3687],"web_image":""}]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Modifier item id (list_modifier_items [].id)."}},"required":["id"]},"module":"modifier_items","safety":"read"},{"name":"create_modifier_item","description":"POST /api/modifierItems. Why: Add an option (e.g. \"Oat milk\" +2.00) to one or more modifier groups at given branches (dashboard Modifier Items › Add). Needs group ids from list_modifiers / create_modifier. Then it shows at the POS under its primary group (list_selling_modifiers) on products that link that group, and its id can be used in update_product modifiers[] default / mandatory / unavailable item lists. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_modifier_item\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/modifierItems","why":"Add an option (e.g. \"Oat milk\" +2.00) to one or more modifier groups at given branches (dashboard Modifier Items › Add). Needs group ids from list_modifiers / create_modifier. Then it shows at the POS under its primary group (list_selling_modifiers) on products that link that group, and its id can be used in update_product modifiers[] default / mandatory / unavailable item lists.","response":{"description":"HTTP 200 with your body echoed plus id, modifier_id and modifier_ids. Server side: inserts modifier_Items (location_id NULL, price = actual_price), sets sku_no \"moditem-<customer_id>-<id>\", inserts modifierItems_locations and modifier_item_assignments (group × branch). Errors: 400 {err:\"name can't be blank\"}; 500 {err:\"Failed to create modifier item\", details} (the row may already exist when a later step failed — check list_modifier_items k=<name>).","fields":{"id":"int. New option id.","modifier_id":"int | null. Primary group stored (= modifier_ids[0]; null when none).","modifier_ids":"int[]. Group ids parsed from your body (written to modifier_item_assignments).","<each body field you sent>":"Echoed back with the same name and raw value you sent. Not a confirmation of stored values — re-read with get_modifier_item.","err":"string. Only on 400/500: \"name can't be blank\" | \"Failed to create modifier item\".","details":"string. Only on 500: the database error message."},"master_rules":["The dashboard Add form has Name (required), Description, Local Name, Local Description, Actual Price (required, default 0, VAT-inclusive, no conversion), Priority (required, ≥ 0, default 0), Modifiers (multi-select, ≥ 1), Tax Group / Tax (out of scope — omit), Locations (≥ 1) and an image; SKU is shown only on edit. Apply the same validation before calling.","Order modifier_ids deliberately: the first id is the group the POS shows the option under; the others only affect list_modifier_items filters and the dashboard's Modifiers column.","Give the option the same branches as its group(s) (list_modifiers location_id); an option outside a branch is hidden from branch-filtered lists.","Kitchen instructions and the image are added after saving through separate routes (no MCP tool). Master tenants do use option recipes (dashboard \"Connect Recipes\"; selling the option through sync_invoice deducts its recipe stock) — also separate routes, not these tools.","The dashboard toasts success without checking the response; you must check for err in the reply."],"example":{"name":"Oat milk","actual_price":2,"modifier_ids":[4417],"location_ids":[907,908],"priority":3,"id":30211,"modifier_id":4417}},"parameters":{"type":"object","properties":{"name":{"type":"string","description":"Option name, e.g."},"description":{"type":"string","description":"Free-text description (max 100 chars; dashboard: \"Description\")."},"actual_price":{"type":"number","description":"THE option price per unit, VAT INCLUDED (dashboard: \"Actual Price\", required, default 0, sent as typed — no VAT maths on either side)."},"modifier_ids":{"type":"array","items":{"type":"number","description":"Modifier group id (list_modifiers [].id)."},"description":"Group(s) the option belongs to (dashboard: \"Modifiers\" multi-select, at least one; options from list_modifiers loc=<branch>)."},"location_ids":{"type":"array","items":{"type":"number","description":"Branch id (list_locations)."},"description":"Branches that carry the option (dashboard: \"Locations\" checkboxes, at least one; sent there as a csv location_id)."},"priority":{"type":"number","description":"Order inside the group, ascending (dashboard: \"Priority\", required, ≥ 0, default 0; the group screen's drag/sort writes 1..n)."},"image":{"type":"string","description":"Relative upload path (e.g."},"tax_id":{"type":"number","description":"Tax id — tax is OUT OF SCOPE: never choose one."},"localize_name":{"type":"string","description":"Translated (e.g."},"localize_description":{"type":"string","description":"Translated description (dashboard: \"Local Description\")."},"sku_no":{"type":"string","description":"Not needed: create overwrites it with \"moditem-<customer_id>-<id>\" for master tenants."}},"required":["name","modifier_ids","location_ids"]},"module":"modifier_items","safety":"write"},{"name":"update_modifier_item","description":"PUT /api/modifierItems/:id. Why: Change an option's name, price, order, groups or branches (dashboard Modifier Items › Edit). Read it with get_modifier_item first and resend actual_price, priority, modifier_ids, location_ids and tax_id — omitted ones are reset or replaced. Per-product defaults / availability of the option are set with update_product, not here; the POS sold-out switch for options (update_product_availability itemType MODIFIER_ITEM) only reaches Grubtech. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_modifier_item\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/modifierItems/:id","why":"Change an option's name, price, order, groups or branches (dashboard Modifier Items › Edit). Read it with get_modifier_item first and resend actual_price, priority, modifier_ids, location_ids and tax_id — omitted ones are reset or replaced. Per-product defaults / availability of the option are set with update_product, not here; the POS sold-out switch for options (update_product_availability itemType MODIFIER_ITEM) only reaches Grubtech.","response":{"description":"HTTP 200 with your body echoed plus id (path string), modifier_id and modifier_ids. An unknown id still returns 200. Branch rows are re-synced in a transaction; modifier_item_assignments are deleted and re-inserted. Error: HTTP 500 {error:\"Update failed\", details}.","fields":{"id":"string. The :id from the path, as a string.","modifier_id":"int | null. Primary group stored (= modifier_ids[0]).","modifier_ids":"int[]. Group ids parsed from your body (written to modifier_item_assignments).","<each body field you sent>":"Echoed back with the same name and raw value you sent. Not a confirmation of stored values — re-read with get_modifier_item.","error":"string. Only on HTTP 500: \"Update failed\".","details":"string. Only on HTTP 500: the database error message."},"master_rules":["The dashboard saves the whole form (every get_modifier_item field plus edits, location_id as csv, tax_id resent); do the same from get_modifier_item, changing only what the user asked.","Leave image out unless the user changes it: the dashboard resends web_image (\"api/uploads/…\") as image, which stores a doubled path; the MCP body takes only the relative path.","To reorder options in a group, send priority 1..n per option with the full record — the dashboard's group screen reorders with only the primary group and so wipes secondary group links.","Removing a branch or group does not touch product settings; to hide an option on one product use update_product unavailable_modifier_item_ids."],"example":{"name":"Oat milk","actual_price":2.5,"modifier_ids":[4417],"location_ids":[907,908],"priority":3,"tax_id":null,"id":"30211","modifier_id":4417}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Modifier item id to update (list_modifier_items [].id)."},"name":{"type":"string","description":"Option name (max 100 chars)."},"description":{"type":"string","description":"Free-text description (max 100 chars; dashboard: \"Description\")."},"actual_price":{"type":"number","description":"THE option price per unit, VAT INCLUDED (dashboard: \"Actual Price\", required, default 0, sent as typed — no VAT maths on either side)."},"modifier_ids":{"type":"array","items":{"type":"number","description":"Modifier group id (list_modifiers [].id)."},"description":"Group(s) the option belongs to (dashboard: \"Modifiers\" multi-select, at least one; options from list_modifiers loc=<branch>)."},"location_ids":{"type":"array","items":{"type":"number","description":"Branch id (list_locations)."},"description":"Branches that carry the option (dashboard: \"Locations\" checkboxes, at least one; sent there as a csv location_id)."},"priority":{"type":"number","description":"Order inside the group, ascending (dashboard: \"Priority\", required, ≥ 0, default 0; the group screen's drag/sort writes 1..n)."},"image":{"type":"string","description":"Relative upload path (e.g."},"tax_id":{"type":"number","description":"Tax id — tax is OUT OF SCOPE: never choose one."},"localize_name":{"type":"string","description":"Translated (e.g."},"localize_description":{"type":"string","description":"Translated description (dashboard: \"Local Description\")."},"sku_no":{"type":"string","description":"SKU (read-only on the dashboard)."}},"required":["id","actual_price","modifier_ids","location_ids","priority"]},"module":"modifier_items","safety":"write"},{"name":"delete_modifier_item","description":"DELETE /api/modifierItems/:id. Why: Permanently remove one option (dashboard row menu › Delete). Deletes the modifier_Items row and its modifier_item_assignments; its branch rows, kitchen instructions, recipes and per-product settings (product_modifier_item_settings) are left behind. Past orders (sync_invoice lines) keep their own copy. Confirm with the user first. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_modifier_item\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/modifierItems/:id","why":"Permanently remove one option (dashboard row menu › Delete). Deletes the modifier_Items row and its modifier_item_assignments; its branch rows, kitchen instructions, recipes and per-product settings (product_modifier_item_settings) are left behind. Past orders (sync_invoice lines) keep their own copy. Confirm with the user first.","response":{"description":"HTTP 200 { status:true, message:\"modifierItem deleted successfully\" }, also for an unknown id. On a DB error no response is sent (the call times out). Verify with get_modifier_item (404 once gone).","fields":{"status":"boolean. Always true when a response is sent.","message":"string. \"modifierItem deleted successfully\"."},"master_rules":["The dashboard asks for confirmation, then shows response.message and refetches; always confirm with the user first — there is no undo.","Afterwards remove the id from products that use it as a default / mandatory / unavailable option (get_product → update_product modifiers[]); the stale settings stay otherwise."],"example":{"status":true,"message":"modifierItem deleted successfully"}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Modifier item id to delete (list_modifier_items [].id)."}},"required":["id"]},"module":"modifier_items","safety":"destructive"},{"name":"list_selling_brands","description":"GET /api/v2/brands. Why: Brands of a branch WITH their groups nested (there is no separate groups route) — what web-pos loads to choose the brand, show the group rail and scope the menu. Each group lists the category ids it contains. Step 1 of building a menu: pick a brand, pick a group, then filter list_selling_categories / list_selling_products by the group's category ids. The chosen brand_id goes on every cart line and on the invoice. Not paginated. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_selling_brands\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/brands","why":"Brands of a branch WITH their groups nested (there is no separate groups route) — what web-pos loads to choose the brand, show the group rail and scope the menu. Each group lists the category ids it contains. Step 1 of building a menu: pick a brand, pick a group, then filter list_selling_categories / list_selling_products by the group's category ids. The chosen brand_id goes on every cart line and on the invoice. Not paginated.","response":{"description":"{ status, message, data: brands[] }. Each brand has groups[]; each group has categories[] (category ids). Error: HTTP 500 { status:false, message, error }.","fields":{"status":"boolean. true = success (also when empty).","message":"string. \"brands found\" | \"No brands found\".","data[].brand_id":"int. Brand id → brand_id on every cart line and on the invoice (web-pos falls back to 1 when no brand is chosen).","data[].brand_name":"string. Brand name to show; also the receipt header (invoice info_name).","data[].brand_address":"string | null. Receipt header address (invoice info_address).","data[].brand_email":"string | null. Receipt header email (invoice info_email).","data[].brand_phone":"string | null. Receipt header phone (invoice info_phone).","data[].brand_thumbnail":"string. Full logo URL, or \"\" = none (brand picker, daily-sales report logo).","data[].location_id":"int. Branch the brand was returned for.","data[].brand_color":"string | null. Hex colour (cosmetic).","data[].brand_vat_no":"string | null. Brand VAT registration no. Not used by web-pos.","data[].groups":"array. The brand's groups ([] = brand has no groups → no menu scoping). Not filtered by branch.","data[].groups[].group_id":"int. Group id.","data[].groups[].group_name":"string. Group name to show (e.g. \"Hot Drinks\").","data[].groups[].group_thumbnail":"string. Full image URL, or \"\".","data[].groups[].group_description":"string | null. Description (not shown by web-pos).","data[].groups[].brand_id":"int. Owning brand (= data[].brand_id).","data[].groups[].categories":"int[]. Category ids in this group (active links only) = list_selling_categories [].id. The same category can be in groups of several brands."},"master_rules":["Choosing the brand (web-pos): the user's own brand_id if it is in the list, else the first brand. Switching brand empties the cart and resets the group.","Choosing the group: default the brand's FIRST group; the user can switch. Then apply the menu scoping below.","Menu scoping (web-pos): brand → groups → category ids → categories → products. Products carry NO brand_id; a brand reaches its products only through its groups' category ids.","Allowed category ids = the selected group's categories (web-pos always selects a group when the brand has groups — default the FIRST group); if that group's list is empty, the union of all the brand's groups; if the brand has no groups, no scoping (everything).","Categories NOT in the allowed ids are hidden — including categories that belong to no group at all. If scoping would hide every category, web-pos shows all (fail-open).","Unify mode (optional app setting): one screen for all brands — web-pos shows a pseudo brand { brand_id: 0, brand_name: \"Unify Brand\" }, then the user picks a real brand. Never send brand_id 0 on a cart line or invoice; use the real brand's id.","Brands carry no VAT setting — VAT comes from the branch config (tax is out of scope)."],"example":{"status":true,"message":"brands found","data":[{"brand_id":459,"brand_name":"Zara","brand_thumbnail":"","location_id":907,"brand_color":"#000","brand_address":"Pakistan","brand_email":"Zara@gmail.com","brand_phone":"12345678","brand_vat_no":"","groups":[{"group_id":1462,"group_name":"Clothing Brand","group_thumbnail":"","group_description":"","brand_id":459,"categories":[17539]},{"group_id":1465,"group_name":"Accessories","group_thumbnail":"","group_description":"","brand_id":459,"categories":[15481,17556,17557,15230]}]}]}},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Optional."}},"required":[]},"module":"brands","safety":"read"},{"name":"list_brands","description":"GET /api/brands. Why: Dashboard catalog view of brands (brand management), not the POS selling view — use list_selling_brands to build a branch's menu (it nests each group's category ids). Use this to find brand ids for get_brand / update_brand / delete_brand, and to see each brand's branches (brands_locations) and group names (brands_groups_relation). A brand reaches categories only through its groups: brand → groups (get_brand groups[].id) → categories (list_categories group_id=… or brand_id=…&loc=…) → products. Not paginated. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_brands\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/brands","why":"Dashboard catalog view of brands (brand management), not the POS selling view — use list_selling_brands to build a branch's menu (it nests each group's category ids). Use this to find brand ids for get_brand / update_brand / delete_brand, and to see each brand's branches (brands_locations) and group names (brands_groups_relation). A brand reaches categories only through its groups: brand → groups (get_brand groups[].id) → categories (list_categories group_id=… or brand_id=…&loc=…) → products. Not paginated.","response":{"description":"HTTP 200 { status: true, message, data: brand[] } — data = [] with message \"Brand not found here\" when none match. Sorted by brand_name, one row per brand. Group names are limited to groups available at one of the brand's own branches (groups_locations or groups.location_id). Error: HTTP 500 { status: false, message: \"Internal Server Error\", error }.","fields":{"status":"boolean. true on success (also when empty).","message":"string. \"Brand found here\" | \"Brand not found here\".","data":"array. Brands.","data[].id":"int. Brand id → get_brand / update_brand / delete_brand, list_categories brand_id, list_selling_brands brand_id, cart/invoice brand_id.","data[].brand_name":"string. Brand name (may have trailing spaces as entered).","data[].brand_thumbnail":"string | null. Logo path RELATIVE to /api/uploads (prefix \"<host>/api/uploads/\"), \"\" or null = none.","data[].description":"string | null. Description.","data[].brand_groups":"string[]. Group NAMES (not ids) linked via brands_groups_relation that are available at the brand's branches; [] = none. For group ids use get_brand.","data[].location_id":"int[]. All branch ids of the brand (brands_locations), regardless of the loc filter. [] = no branch.","data[].brand_color":"string | null. Hex colour. Cosmetic.","data[].brand_address":"string | null. Receipt-header address.","data[].brand_email":"string | null. Receipt-header email.","data[].brand_phone":"string | null. Receipt-header phone.","data[].brand_vat_no":"string | null. Brand VAT number (text). Not used for tax.","error":"string. Only on 500: DB error text."},"example":{"status":true,"message":"Brand found here","data":[{"id":459,"brand_name":"Zara","brand_thumbnail":"","description":"Clothing Brand","brand_groups":["Clothing Brand","Accessories"],"location_id":[922,907,908,909,910,1131],"brand_color":"#000","brand_address":"Pakistan","brand_email":"Zara@gmail.com","brand_phone":"12345678","brand_vat_no":""}]},"master_rules":["Use this for dashboard brand management and brand dropdowns (label brand_name, value id), not for selling: to build a branch menu use list_selling_brands, which nests each group's category ids.","Dashboard Brands list shows brand_name and brand_groups (group NAMES as chips, \"No groups\" when empty); edit and delete act on data[].id. Show the same to the user.","brand_groups here are NAMES. Never send them to update_brand — take group ids from get_brand brand_groups / groups[].id or list_groups brand_id=<id>.","Menu linkage: brand → groups (get_brand groups[].id, list_groups brand_id) → category ids (list_groups data[].categories[].id, list_categories group_id=… or brand_id=…&loc=…) → products (list_products category_id). Products carry NO brand_id.","location_id = the brand's branches (list_locations ids, brands_locations). A brand with [] is on no branch: it disappears from every loc-filtered list and from list_selling_brands.","Receipt header: brand_name / brand_address / brand_phone / brand_email are what the POS prints; pass them as sync_invoice info_name / info_address / info_phone / info_email and the brand id as sync_invoice brand_id (take ids from list_selling_brands when selling).","Filter other tools by this id: list_groups brand_id, list_categories brand_id (only together with loc), list_modifiers / list_modifier_items / list_products brand_id (slow — prefer category ids).","The product list shows its Brand filter only when the tenant has more than one brand; with one brand, brand scoping is irrelevant."]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id(s), csv."}},"required":[]},"module":"brands","safety":"read"},{"name":"get_brand","description":"GET /api/brands/:id. Why: Full dashboard record of ONE brand: every column, branch ids and its groups with ids. Required before update_brand (which overwrites every field, group link and branch link) and to get the brand's group ids for list_categories group_id / get_group. For the POS menu use list_selling_brands. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_brand\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/brands/:id","why":"Full dashboard record of ONE brand: every column, branch ids and its groups with ids. Required before update_brand (which overwrites every field, group link and branch link) and to get the brand's group ids for list_categories group_id / get_group. For the POS menu use list_selling_brands.","response":{"description":"HTTP 200 with a one-element array [brand] (SELECT * from brands + groups), or the JSON STRING \"No records found\" (not an array) when the id does not exist. The server does NOT check the tenant — ids of other tenants are returned too (verified live), so only use ids from list_brands. groups is limited to groups available at the brand's branches. Error: HTTP 500 { message: \"Internal server error\" }.","fields":{"[]":"array. Exactly one brand. (Not found → the string \"No records found\".)","[0].id":"int. Brand id → update_brand body id.","[0].brand_name":"string | null. Brand name → update_brand brand_name.","[0].brand_thumbnail":"string | null. Logo path relative to /api/uploads, \"\"/null = none → update_brand brand_thumbnail.","[0].location_id":"int[]. Branch ids (brands_locations); [] = none → join with \",\" for update_brand location_id.","[0].customer_id":"int. Owning tenant. Should equal your session tenant; if not, you fetched a foreign brand — ignore it.","[0].description":"string | null. Description.","[0].brand_groups":"string. CSV of group ids currently linked AND available at the brand's branches (\"1462,1465\"), \"\" = none → send back as update_brand brand_groups. (Overwrites the legacy brands.brand_groups column of the same name.)","[0].brand_color":"string | null. Hex colour.","[0].user_id":"int | null. User who last created/updated the brand. Not needed.","[0].brand_address":"string | null. Receipt-header address.","[0].brand_email":"string | null. Receipt-header email.","[0].brand_phone":"string | null. Receipt-header phone.","[0].brand_vat_no":"string | null. VAT number text.","[0].sku_no":"string | null. Auto code \"brd-<customer_id>-<id>\". Informational.","[0].groups":"array. Groups linked via brands_groups_relation (available at the brand's branches); [] = none. Unordered.","[0].groups[].id":"int. Group id → get_group, list_categories group_id.","[0].groups[].group_name":"string. Group name."},"example":[{"id":459,"brand_name":"Zara","brand_thumbnail":"","location_id":[922,907,908,909,910,1131],"customer_id":437,"description":"Clothing Brand","brand_groups":"1462,1465","brand_color":"#000","user_id":4734,"brand_address":"Pakistan","brand_email":"Zara@gmail.com","brand_phone":"12345678","brand_vat_no":"","sku_no":"brd-437-459","groups":[{"id":1462,"group_name":"Clothing Brand"}]}],"master_rules":["Check Array.isArray(result) before reading [0]; the string \"No records found\" means the id does not exist.","Check [0].customer_id equals your tenant; the server returns other tenants' brands too.","Dashboard edit form pre-fills from [0]: brand_name, description, brand_address (Address), brand_email (Email), brand_phone (Phone), brand_vat_no (VAT), brand_color (default \"#000\"), location_id (joined to csv), brand_groups (csv → ids, labels from groups[]), brand_thumbnail (image preview) and sku_no (read-only).","To build update_brand: copy every field above, set location_id = [0].location_id.join(\",\") and brand_groups = [0].brand_groups (csv string), then change only what the user asked for.","groups / brand_groups only include groups available at one of the brand's branches. A linked group that is on none of them is missing here and is DROPPED by the next update_brand — before updating, compare with list_groups brand_id=<id> and add missing ids if the user wants to keep them.","Groups[].id → get_group / update_group; their categories are the brand's menu (list_selling_brands shows the same with category ids per branch)."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Brand id (list_brands data[].id)."}},"required":["id"]},"module":"brands","safety":"read"},{"name":"create_brand","description":"POST /api/brands. Why: Create a new brand (dashboard catalog management) on one or more branches, optionally linking existing groups. The new id is what list_selling_brands returns as brand_id and what cart lines/invoices carry. A brand shows categories only through its groups — create groups (create_group) and link them here via brand_groups or later with update_brand. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_brand\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/brands","why":"Create a new brand (dashboard catalog management) on one or more branches, optionally linking existing groups. The new id is what list_selling_brands returns as brand_id and what cart lines/invoices carry. A brand shows categories only through its groups — create groups (create_group) and link them here via brand_groups or later with update_brand.","response":{"description":"HTTP 200 { success: true, brand_ids: [newId] } — one id on master tenants. Inserts one brands row (location_id NULL, sku_no filled asynchronously), one brands_locations row per branch and one brands_groups_relation row per group. If the duplicate check hits (only possible for non-master rows) it answers HTTP 200 with an EMPTY body (null). Error: HTTP 500 { success: false, message: \"Internal server error\" } (also for a non-string location_id / brand_groups).","fields":{"success":"boolean. true on success; false on 500.","brand_ids":"int[]. New brand id(s); [0] → get_brand / update_brand.","message":"string. Only on 500: \"Internal server error\"."},"example":{"success":true,"brand_ids":[480]},"master_rules":["Send brand_name, location_id and brand_groups exactly like the dashboard Add Brand form: location_id and brand_groups as CSV STRINGS (\"907,908\"), never arrays or numbers (→ HTTP 500).","Always pass location_id explicitly; if omitted MCP injects the key's branches as a number/array and the server returns 500.","Before creating, call list_brands and stop (ask the user) if a brand with the same name (trimmed, case-insensitive) exists — master tenants get no server duplicate check.","Optional fields map to the form: description, brand_address (Address), brand_email (Email), brand_phone (Phone), brand_vat_no (VAT), brand_color (default \"#000\"), brand_thumbnail (upload file name; there is no upload tool — reuse an existing path or omit).","Check success === true and read brand_ids[0]; a null/empty body means nothing was created. The dashboard does not check the reply — you must.","A new brand has no menu until it has groups with categories: pass brand_groups (ids from list_groups) or create_group with brand = the new id and categories = csv of list_categories ids. Confirm with get_brand and list_selling_brands."]},"parameters":{"type":"object","properties":{"brand_name":{"type":"string","description":"Brand name (brands.brand_name, max 100)."},"location_id":{"type":"string","description":"Branches of the brand as a CSV STRING, e.g."},"brand_groups":{"type":"string","description":"Group ids as a CSV STRING (\"1462,1465\") — one brands_groups_relation row per id (brand ↔ group; a group's categories then define the brand's menu)."},"description":{"type":"string","description":"Free-text description (brands.description)."},"brand_thumbnail":{"type":"string","description":"Logo path relative to /api/uploads (e.g."},"brand_color":{"type":"string","description":"Hex colour, e.g."},"brand_address":{"type":"string","description":"Address printed in the receipt header for this brand (invoice info_address)."},"brand_email":{"type":"string","description":"Email printed in the receipt header (invoice info_email)."},"brand_phone":{"type":"string","description":"Phone printed in the receipt header (invoice info_phone)."},"brand_vat_no":{"type":"string","description":"Brand VAT registration number (text only; VAT itself comes from branch config)."}},"required":["brand_name","location_id"]},"module":"brands","safety":"write"},{"name":"update_brand","description":"PUT /api/brands/:id. Why: Edit one brand: name, logo, receipt-header contact fields, branches and linked groups. It is a FULL REPLACE — call get_brand first and resend every field, brand_groups (else all groups are unlinked) and location_id (else branches are reset). Use it to attach/detach groups (and therefore categories) to a brand. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_brand\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/brands/:id","why":"Edit one brand: name, logo, receipt-header contact fields, branches and linked groups. It is a FULL REPLACE — call get_brand first and resend every field, brand_groups (else all groups are unlinked) and location_id (else branches are reset). Use it to attach/detach groups (and therefore categories) to a brand.","response":{"description":"HTTP 200 { success: true, brand_id } — no brand object; call get_brand. Always success even when the id matches nothing. Side effects: UPDATE brands (all columns, user_id = you; not scoped to your tenant), DELETE + re-INSERT brands_groups_relation from brand_groups, DELETE + re-INSERT brands_locations from location_id. Errors: 400 { success:false, message:\"Brand name already exists\" } (only with name_update); 500 { success:false, message:\"Internal server error\" } (also for non-string location_id / brand_groups).","fields":{"success":"boolean. true on success; false on 400/500.","brand_id":"number | string. The body id echoed back (as you sent it).","message":"string. Only on 400/500."},"example":{"success":true,"brand_id":459},"master_rules":["FULL REPLACE: call get_brand first and resend id, brand_name, description, brand_thumbnail, brand_color, brand_address, brand_email, brand_phone, brand_vat_no, location_id (csv) and brand_groups (csv). Anything omitted becomes NULL / unlinked.","The handler reads the brand id from the BODY; always pass id (MCP copies it into the URL and body).","location_id and brand_groups must be CSV STRINGS; an array or number → HTTP 500 before anything is written.","Renaming: send name_update: 1 to get the 400 \"Brand name already exists\" check (the dashboard never sends it).","Unlinking a group = resend brand_groups without its id; the group itself stays. update_group brand edits the same brand↔group link from the other side.","Changing location_id replaces all branch links; the brand vanishes from list_selling_brands / list_brands loc=… of removed branches.","The reply has no brand data and is success even for a wrong id — confirm with get_brand. The dashboard does not check the reply either."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Brand id (list_brands)."},"brand_name":{"type":"string","description":"Brand name (brands.brand_name, max 100)."},"location_id":{"type":"string","description":"Branches of the brand as a CSV STRING, e.g."},"brand_groups":{"type":"string","description":"Group ids as a CSV STRING (\"1462,1465\") — REPLACES all brands_groups_relation rows of this brand."},"description":{"type":"string","description":"Free-text description (brands.description)."},"brand_thumbnail":{"type":"string","description":"Logo path relative to /api/uploads."},"brand_color":{"type":"string","description":"Hex colour, e.g."},"brand_address":{"type":"string","description":"Address printed in the receipt header for this brand (invoice info_address)."},"brand_email":{"type":"string","description":"Email printed in the receipt header (invoice info_email)."},"brand_phone":{"type":"string","description":"Phone printed in the receipt header (invoice info_phone)."},"brand_vat_no":{"type":"string","description":"Brand VAT registration number (text only; VAT itself comes from branch config)."},"image":{"type":"string","description":"Alias for brand_thumbnail (used only when brand_thumbnail is absent); a full uploads URL is cut to the relative path."},"name_update":{"type":"string","description":"Truthy = check that no OTHER brand of this tenant already has brand_name → 400 { success:false, message:\"Brand name already exists\" }."}},"required":["id","brand_name","location_id"]},"module":"brands","safety":"write"},{"name":"delete_brand","description":"DELETE /api/brands/:id. Why: Permanently delete a brand row. No safety checks: nothing blocks deleting a brand that is in use, and its groups, categories and products are untouched (groups just lose the brand). Prefer editing with update_brand; confirm the id via get_brand (customer_id must be yours) first. Irreversible. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_brand\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/brands/:id","why":"Permanently delete a brand row. No safety checks: nothing blocks deleting a brand that is in use, and its groups, categories and products are untouched (groups just lose the brand). Prefer editing with update_brand; confirm the id via get_brand (customer_id must be yours) first. Irreversible.","response":{"description":"ALWAYS HTTP 200 { status: true, message: \"Brand deleted successfully.\" } — even when the id does not exist, belongs to another tenant, or the DELETE failed; verify with list_brands. Only the brands row is deleted; brands_locations and brands_groups_relation rows are left orphaned.","fields":{"status":"boolean. Always true (not a reliable success signal).","message":"string. Always \"Brand deleted successfully.\"."},"example":{"status":true,"message":"Brand deleted successfully."},"master_rules":["Ask the user to confirm first (the dashboard shows a confirm dialog before its trash icon deletes, then toasts \"Brand Deleted Successfully!\" when status is true).","Only delete ids from your own list_brands; check get_brand [0].customer_id first — the server does not.","The reply is always success; verify with list_brands that the id is gone.","Groups, categories and products are NOT deleted; the groups just lose this brand. Ask whether to also update_group / delete_group them. Past orders keep the old brand_id."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Brand id (list_brands)."}},"required":["id"]},"module":"brands","safety":"destructive"},{"name":"list_groups","description":"GET /api/groups. Why: List the tenant's groups with their linked categories, brands and branches — use it to see how a brand maps to categories, to find the group id to edit, or to check which categories a brand's menu will show. Produces group ids (get_group / update_group / delete_group); categories[].id feed list_categories/list_products filters; brand[].id are list_brands ids. Catalog management (dashboard) tool. For menu scoping on the POS use list_selling_brands, which already returns each brand's groups with their category ids. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_groups\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/groups","why":"List the tenant's groups with their linked categories, brands and branches — use it to see how a brand maps to categories, to find the group id to edit, or to check which categories a brand's menu will show. Produces group ids (get_group / update_group / delete_group); categories[].id feed list_categories/list_products filters; brand[].id are list_brands ids. Catalog management (dashboard) tool. For menu scoping on the POS use list_selling_brands, which already returns each brand's groups with their category ids.","response":{"description":"{status:true, message:\"Groups found\"|\"No groups found\", data: groups[]} (data [] when none). Server filters: tenant = session customer, loc and brand_id as above; there is no active/deleted flag on groups. Order: menu_sort_order, group_name — but with loc, branch-linked groups come first and legacy location_id groups after. Each row is rebuilt by the handler: group_description, group_thumbnail, sku_no and user_id are NOT returned (use get_group). DB error → HTTP 500 {status:false, message:\"Internal Server Error\", error}. Example: live dev1 row (tenant 437).","fields":{"status":"boolean. true on success (also when empty).","message":"string. \"Groups found\" or \"No groups found\".","data":"array. Groups.","data[].id":"number. Group id — use with get_group / update_group / delete_group.","data[].group_name":"string. Group display name.","data[].description":"never present. The handler reads a non-existent `description` column, so the key is dropped from the JSON. Use get_group.group_description.","data[].brand_id":"never present. Reads a non-existent column; brands are in data[].brand[].","data[].is_active":"never present. Reads a non-existent column; groups have no active flag.","data[].customer_id":"number. Tenant id. Not needed.","data[].location_id":"array<number>|number. Branch ids from groups_locations (NOT deduplicated — duplicate link rows show as repeated ids; dedupe before use). [] = in no branch. A plain number only for legacy groups whose groups.location_id is set.","data[].menu_sort_order":"number. Sort position (ascending).","data[].categories":"array. Categories linked through group_categories — only categories with is_active = 1. These are the category ids a brand's menu shows when this group is selected.","data[].categories[].id":"number. Category id (list_categories / list_products category_id filter).","data[].categories[].name":"string. Category name.","data[].brand":"array. Brands linked through brands_groups_relation (usually one).","data[].brand[].id":"number. Brand id (list_brands).","data[].brand[].brand_name":"string. Brand name."},"example":{"status":true,"message":"Groups found","data":[{"id":1341,"group_name":"Flavor you need","customer_id":437,"location_id":[907,908,909,910,922,907,908,909,910,922,1131],"menu_sort_order":0,"categories":[{"id":15481,"name":"Alternate Options"},{"id":15230,"name":"Brewed Coffee"},{"id":15231,"name":"Espresso-Based Drinks"},{"id":15266,"name":"Iced & Cold Coffees"},{"id":16686,"name":"Sandwiches"},{"id":15430,"name":"Specialty & Flavored Coffees"},{"id":16707,"name":"Coffee"}],"brand":[{"id":412,"brand_name":"Nexus Cafe"}]}]},"master_rules":["Use for dashboard group management and to map categories to brands; for the POS menu use list_selling_brands (same links, per branch, category ids only).","Dashboard Groups list shows group_name, category names (chips) and menu_sort_order, in the server order; edit/delete act on data[].id. Show the same.","Menu linkage: a brand shows exactly the categories of its groups: brand[].id → this group → categories[].id → list_selling_categories / list_products category_id. Products carry NO brand_id.","To find which brand(s) sell a category, find the groups whose categories[].id contains it and read their brand[] (the dashboard product list builds its category→brand map this way).","Deduplicate location_id before showing or resending it; ids are list_locations ids (groups_locations).","categories[] lists ACTIVE categories only; links to inactive categories exist but are hidden (and are dropped by update_group if you resend this list).","A group with brand [] or categories [] contributes nothing to any menu — tell the user."]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id or csv."},"brand_id":{"type":"number","description":"Brand id (list_brands): keeps only groups linked to this brand in brands_groups_relation."}},"required":[]},"module":"groups","safety":"read"},{"name":"get_group","description":"GET /api/groups/:id. Why: Load one group with every column plus its categories, brands and branches — required before update_group (which replaces everything). Consumes a group id from list_groups or list_selling_brands. Catalog management (dashboard) tool. For menu scoping on the POS use list_selling_brands, which already returns each brand's groups with their category ids. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_group\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/groups/:id","why":"Load one group with every column plus its categories, brands and branches — required before update_group (which replaces everything). Consumes a group id from list_groups or list_selling_brands. Catalog management (dashboard) tool. For menu scoping on the POS use list_selling_brands, which already returns each brand's groups with their category ids.","response":{"description":"A one-element ARRAY [group] (take [0]): SELECT * FROM groups plus location_id (branch ids), categories and brand replaced by the linked rows. Unknown id → HTTP 200 [] (empty array, not 404). The server does NOT check the tenant. DB error → HTTP 500 {error:\"Failed to fetch group\", detail}. Example: live dev1 group 1341.","fields":{"[]":"array with one group object, or [] when the id does not exist.","[].id":"number. Group id.","[].group_name":"string. Group name.","[].group_thumbnail":"string|null. Tile image relative path. Send back as group_thumbnail on update_group.","[].group_description":"string|null. Description. Send back on update_group.","[].categories":"array. Linked categories (group_categories JOIN categories, is_active = 1 only) — replaces the legacy csv column of the same name. Send the ids back as a csv string in update_group.categories.","[].categories[].id":"number. Category id.","[].categories[].name":"string. Category name.","[].location_id":"array<number>|number. Branch ids from groups_locations (not deduplicated), [] when none; a plain number only for legacy rows with groups.location_id set. Send back (deduplicated) as update_group.location_id.","[].customer_id":"number. Owning tenant id. Check it equals your tenant — the server does not.","[].brand":"array. Linked brands (brands_groups_relation JOIN brands) — replaces the legacy csv column. Send the ids back as a csv string in update_group.brand.","[].brand[].id":"number. Brand id.","[].brand[].brand_name":"string. Brand name.","[].user_id":"number|null. Id of the user who last created/updated the group. Not needed.","[].sku_no":"string|null. Group SKU, generated as grp-<customer_id>-<id> on master tenants. Not needed.","[].menu_sort_order":"number. Sort position (ascending). Send back on update_group."},"example":[{"id":1341,"group_name":"Flavor you need","group_thumbnail":"1759910641566-coffee.jpg","group_description":"Flavor you need","categories":[{"id":15481,"name":"Alternate Options"},{"id":15230,"name":"Brewed Coffee"},{"id":15231,"name":"Espresso-Based Drinks"},{"id":15266,"name":"Iced & Cold Coffees"},{"id":16686,"name":"Sandwiches"},{"id":15430,"name":"Specialty & Flavored Coffees"},{"id":16707,"name":"Coffee"}],"location_id":[907,908,909,910,922,907,908,909,910,922,1131],"customer_id":437,"brand":[{"id":412,"brand_name":"Nexus Cafe"}],"user_id":4734,"sku_no":"grp-437-1341","menu_sort_order":0}],"master_rules":["[] means not found; otherwise use [0] and check [0].customer_id equals your tenant (the server does not).","Dashboard edit form pre-fills from [0]: group_name, group_description, menu_sort_order, location_id (joined csv), categories (ids), brand (ids), group_thumbnail (preview), sku_no (read-only).","To build update_group: group_name, group_description, group_thumbnail, menu_sort_order as returned; categories = [0].categories ids joined with \",\"; brand = [0].brand ids joined with \",\"; location_id = deduplicated [0].location_id array. Then change only what the user asked.","Inactive categories are not returned, so resending categories drops their links (the dashboard does the same) — mention it if the user expects them to stay."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Group id (list_groups data[].id)."}},"required":["id"]},"module":"groups","safety":"read"},{"name":"create_group","description":"POST /api/groups. Why: Create a group to attach categories to a brand's menu (e.g. \"Hot drinks\" under brand Nexus Cafe). Pass categories and brand as csv strings and location_id as an array. Produces group_id for get_group / update_group. Catalog management (dashboard) tool. For menu scoping on the POS use list_selling_brands, which already returns each brand's groups with their category ids. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_group\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/groups","why":"Create a group to attach categories to a brand's menu (e.g. \"Hot drinks\" under brand Nexus Cafe). Pass categories and brand as csv strings and location_id as an array. Produces group_id for get_group / update_group. Catalog management (dashboard) tool. For menu scoping on the POS use list_selling_brands, which already returns each brand's groups with their category ids.","response":{"description":"HTTP 200 {success:true, group_id}. Errors: 400 {err:\"Group name can't be blank\"}; 500 {success:false, message:\"Database error while creating group.\", error}. Not transactional: a failure after the insert leaves a partial group. categories/brand sent as arrays throw before the try block → no response. The legacy groups.categories / groups.brand csv columns are also stored. SKU generated asynchronously on master. Example derived from code.","fields":{"success":"boolean. true on success, false on DB error.","group_id":"number. New group id — use with get_group / update_group / delete_group.","err":"string. Validation error (400 only).","message":"string. Error summary (500 only).","error":"string. Underlying DB error (500 only)."},"example":{"success":true,"group_id":1486},"master_rules":["Send what the dashboard Add Group form sends: group_name (required), group_description, menu_sort_order (default 0), location_id (branches, at least one), categories (csv of list_categories ids), brand (csv of list_brands ids, at least one), group_thumbnail (upload file name or omit).","categories and brand must be CSV STRINGS on create — an array hangs the request with no reply and nothing is stored.","Always pass location_id as an array of list_locations ids; without branch rows the group is invisible in list_groups loc=…, get_brand and list_brands (their group lists are branch-filtered).","Link at least one brand (the dashboard requires it); a group without a brand never reaches a menu.","Check list_groups first for the same group_name — the server allows duplicates.","Check success === true and group_id; on 400 read err, on 500 read message/error. The dashboard does not check the reply — you must. Then confirm with get_group."]},"parameters":{"type":"object","properties":{"group_name":{"type":"string","description":"Required."},"group_description":{"type":"string","description":"Free-text description (max 200 chars)."},"group_thumbnail":{"type":"string","description":"Image path or URL for the group tile (max 100 chars)."},"image":{"type":"string","description":"update_group only: alias of group_thumbnail, used when group_thumbnail is absent."},"location_id":{"type":"array","items":{"type":"number","description":"Branch id (list_locations)."},"description":"Branch ids the group belongs to (groups_locations rows)."},"categories":{"type":"string","description":"CSV string of category ids (list_categories), e.g."},"brand":{"type":"string","description":"CSV string of brand ids (list_brands), e.g."},"menu_sort_order":{"type":"number","description":"Sort position (ascending) in lists and menus."}},"required":["group_name","location_id"]},"module":"groups","safety":"write"},{"name":"update_group","description":"PUT /api/groups/:id. Why: Rename a group, change its categories, brands or branches. FULL REPLACE — read get_group first and send every field back (categories and brand as csv of ids, location_id as array), or links are wiped. Consumes a group id from list_groups/get_group. Catalog management (dashboard) tool. For menu scoping on the POS use list_selling_brands, which already returns each brand's groups with their category ids. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_group\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/groups/:id","why":"Rename a group, change its categories, brands or branches. FULL REPLACE — read get_group first and send every field back (categories and brand as csv of ids, location_id as array), or links are wiped. Consumes a group id from list_groups/get_group. Catalog management (dashboard) tool. For menu scoping on the POS use list_selling_brands, which already returns each brand's groups with their category ids.","response":{"description":"HTTP 200 {success:true, message:\"Group updated successfully\", group_id}. Error → 500 {success:false, message:\"Internal server error\", error}. No 404 and no tenant check: an unknown id still returns success (and inserts orphan link rows). Not transactional. On update categories/brand may also be arrays. The legacy groups.categories / groups.brand csv columns are NOT updated (get_group/list_groups ignore them). Example derived from code.","fields":{"success":"boolean. true on success.","message":"string. \"Group updated successfully\" or \"Internal server error\".","group_id":"string. The :id path value, as a STRING.","error":"string. Underlying error (500 only)."},"example":{"success":true,"message":"Group updated successfully","group_id":"1341"},"master_rules":["FULL REPLACE: call get_group first and resend group_name, group_description, group_thumbnail, menu_sort_order, location_id, categories (csv) and brand (csv). Omitted links are deleted; omitted columns become NULL (menu_sort_order 0).","Adding a category to a brand's menu = resend this group's categories csv with the new id appended; removing = resend without it.","Reordering: menu_sort_order is ascending; the dashboard renumbers all groups 1..n via a bulk route with no MCP tool — to move one group, update_group its menu_sort_order (full body).","update_group brand and update_brand brand_groups edit the same brand↔group links; after either, the other side's get_* reflects it.","The reply is success even for an unknown id (group_id is echoed as a string); confirm with get_group. The dashboard does not check the reply."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Group id (list_groups / get_group)."},"group_name":{"type":"string","description":"Required."},"group_description":{"type":"string","description":"Free-text description (max 200 chars)."},"group_thumbnail":{"type":"string","description":"Image path or URL for the group tile (max 100 chars)."},"image":{"type":"string","description":"update_group only: alias of group_thumbnail, used when group_thumbnail is absent."},"location_id":{"type":"array","items":{"type":"number","description":"Branch id (list_locations)."},"description":"Branch ids the group belongs to (groups_locations rows)."},"categories":{"type":"string","description":"CSV string of category ids (list_categories), e.g."},"brand":{"type":"string","description":"CSV string of brand ids (list_brands), e.g."},"menu_sort_order":{"type":"number","description":"Sort position (ascending) in lists and menus."}},"required":["id","group_name","location_id"]},"module":"groups","safety":"write"},{"name":"delete_group","description":"DELETE /api/groups/:id. Why: Permanently delete a group (hard delete; no undo). Its brand loses those categories on the POS menu. Consumes a group id from list_groups. Catalog management (dashboard) tool. For menu scoping on the POS use list_selling_brands, which already returns each brand's groups with their category ids. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_group\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/groups/:id","why":"Permanently delete a group (hard delete; no undo). Its brand loses those categories on the POS menu. Consumes a group id from list_groups. Catalog management (dashboard) tool. For menu scoping on the POS use list_selling_brands, which already returns each brand's groups with their category ids.","response":{"description":"{status:true, message:\"Group deleted successfully\"} (sent as text; parse as JSON). Always true when it answers: no existence or tenant check. Only the groups row is deleted — group_categories, brands_groups_relation and groups_locations rows are left orphaned (harmless for reads, which join from groups). A DB error is unhandled (no response). Example derived from code.","fields":{"status":"boolean. Always true.","message":"string. \"Group deleted successfully\"."},"example":{"status":true,"message":"Group deleted successfully"},"master_rules":["Ask the user to confirm first (the dashboard trash icon shows a confirm dialog, then toasts the reply message).","Only delete ids from your own list_groups; check get_group [0].customer_id first.","The reply is always success; verify with list_groups.","Effect: the linked brand(s) lose this group's categories on the POS menu; categories and products stay. If it was a brand's only group the POS shows ALL categories for that brand (no scoping) — warn the user, or move the categories to another group with update_group first."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Group id (list_groups data[].id)."}},"required":["id"]},"module":"groups","safety":"destructive"},{"name":"list_selling_addons","description":"GET /api/v2/products. Why: Add-ons of the session branch (web-pos: GET /api/v2/products?addon=1). An add-on is a small product added ON TOP of a cart line (e.g. extra sandwich with a coffee). Show an add-on for a line only when the add-on's `type` (comma-separated category names) contains the parent product's category name (`type`). Each chosen add-on is charged actual_price × qty and becomes its own invoice line with is_addon = 1 linked to the parent. Same row shape and paging as list_selling_products. web-pos offers NO add-ons when a product is added from search or favourites (no category selected). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_selling_addons\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/products","why":"Add-ons of the session branch (web-pos: GET /api/v2/products?addon=1). An add-on is a small product added ON TOP of a cart line (e.g. extra sandwich with a coffee). Show an add-on for a line only when the add-on's `type` (comma-separated category names) contains the parent product's category name (`type`). Each chosen add-on is charged actual_price × qty and becomes its own invoice line with is_addon = 1 linked to the parent. Same row shape and paging as list_selling_products. web-pos offers NO add-ons when a product is added from search or favourites (no category selected).","response":{"description":"{ status, message, data: { current_page, records_per_page, total_records, products[] } }. Branch = the session branch (the API key's linked user). This route has NO branch parameter on master tenants — you cannot read another branch's menu with it. ALWAYS check status: on a server error the same shape comes back with status:false and products:[]. Use actual_price as the price (VAT included); ignore price and vat.","fields":{"data.products[]":"Same fields as list_selling_products rows. Differences below.","data.products[].type":"string. Comma-separated CATEGORY NAMES this add-on is offered under, e.g. \"Brewed Coffee,Espresso-Based Drinks\". Offer it on a cart line when the parent product's type is in this list.","data.products[].actual_price":"number. THE add-on price per unit, VAT included.","data.products[].is_addon":"1 always.","data.products[].modifiers":"array. Usually [] — add-ons have no option groups.","data.products[].price":"number. NOT USED — here it is the ex-VAT price (actual_price − vat). Use actual_price.","data.products[].vat":"number. NOT USED — VAT amount inside actual_price (tax out of scope).","data.products[].(other fields)":"Every other field has the same meaning as in list_selling_products."},"master_rules":["Master tenants keep one master product and one row per branch (products_locations).","A product is returned only if it has a row for the session branch (or a shared row with no branch).","Products switched off at the branch (is_available = 0) are NOT returned at all; sold-out ones ARE returned with is_sold_out = 1.","Branch row wins for name, description, actual_price and is_sold_out.","modifiers[] links: the branch-specific link wins, then the shared link, then a link made for another branch.","Menu scoping (web-pos): brand → groups → category ids → categories → products. Products carry NO brand_id; a brand reaches its products only through its groups' category ids.","Allowed category ids = the selected group's categories (web-pos always selects a group when the brand has groups — default the FIRST group); if that group's list is empty, the union of all the brand's groups; if the brand has no groups, no scoping (everything).","Categories NOT in the allowed ids are hidden — including categories that belong to no group at all. If scoping would hide every category, web-pos shows all (fail-open).","Option picker (web-pos): pre-tick the product's auto_selected_modifier_items (at most max_allowed per group); if a group has none and min_allowed >= 1 and max_allowed = 1, pre-tick the group's FIRST item. An option can have quantity > 1 only when max_allowed is 0 or > 1; the total quantity across a group must stay <= max_allowed (when > 0).","Cart merge (web-pos): adding the same product with identical options, add-ons, extras and combo children (and, for is_open_price, the same typed price) increases qty on the existing cart line instead of adding a new line.","Products in a category (web-pos grid): rows whose type EXACTLY equals the category name (case-sensitive); only if none, rows whose category_id = the category id or whose type matches case-insensitively. Then drop combos (is_combo != 0) and rows whose category_id is not in the allowed ids (rows without a category_id pass). Sort ascending by category_sort_order (null = 0), ties in id order."],"example":{"status":true,"message":"Data found ","data":{"current_page":1,"records_per_page":50,"total_records":7,"products":[{"id":257559,"category_id":null,"name":"Classic Sandwich","description":"","price":95,"stock":0,"type":"Alternate Options,Brewed Coffee,Espresso-Based Drinks,Iced & Cold Coffees,Specialty & Flavored Coffees","image":"","customer_id":437,"location_id":907,"is_addon":1,"addons":"[]","bundle_items":"[]","is_disabled":0,"discount_pct":0,"modifiers":[],"is_archive":0,"barcode_type":"","barcode_value":"","is_open_price":0,"is_combo":0,"sync_deliverect":0,"sync_otter":0,"new_cost_date":null,"product_cost":0,"is_weightage_item":0,"unit_id":null,"actual_price":100,"old_price":null,"vat":5,"localized":null,"base_price":null,"menu_sort_order":0,"plu":0,"calories":null,"is_stock_product":null,"is_auto_apply":null,"combo_price":0,"localize_name":"","localize_description":"","label_id":null,"recipes":null,"sku_no":"prod-437-257559","tax_id":null,"preparation_time":0,"product_recipe_total":0,"product_material_total":0,"is_overridden":0,"is_deleted":0,"category_sort_order":null,"is_sold_out":0,"auto_selected_modifier_items":[],"price_levels":[],"tax_name":null,"tax_value":null,"image_path":"","web_image":""}]}}},"parameters":{"type":"object","properties":{"page_limit":{"type":"number","description":"Optional."},"current_page":{"type":"number","description":"Optional."},"k":{"type":"string","description":"Optional."},"cat":{"type":"string","description":"Optional."}},"required":[]},"module":"addons","safety":"read"},{"name":"list_addons","description":"GET /api/products/webProducts. Why: Dashboard view of the tenant's add-ons (products with is_addon = 1): ids, master and branch prices, offer list (type), branch count, channel prices. Use it to find add-on ids for get_addon → update_addon / archive_product / delete_addon. Catalog management tool — what the POS offers on a cart line is list_selling_addons. Parent products: list_products / list_selling_products; `type` holds category names from list_categories / list_selling_categories; loc takes list_locations ids; channel_id takes list_channels ids. Per-branch availability and modifier/recipe links are NOT here — call get_addon. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_addons\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/products/webProducts","why":"Dashboard view of the tenant's add-ons (products with is_addon = 1): ids, master and branch prices, offer list (type), branch count, channel prices. Use it to find add-on ids for get_addon → update_addon / archive_product / delete_addon. Catalog management tool — what the POS offers on a cart line is list_selling_addons. Parent products: list_products / list_selling_products; `type` holds category names from list_categories / list_selling_categories; loc takes list_locations ids; channel_id takes list_channels ids. Per-branch availability and modifier/recipe links are NOT here — call get_addon.","response":{"description":"200 → { data: rows[], total } — no status/message keys. Each row = every products column (SELECT *) plus override_* from one joined branch row, resolved image fields, channel_price[], nameChannels, total_locations and has_recipe. Server filters: tenant = session, is_addon = 1, is_archive = <is_archive> (default 0), is_disabled = 0, is_combo = 0, is_deleted = 0, and (with loc) branch membership. products_locations.is_available and is_sold_out are NOT filtered or returned. price and actual_price are both the master VAT-inclusive price; the branch price is override_actual_price. DB error → 500 {status:false, message:\"Internal Server Error\", error}; through MCP an HTTP error arrives as {error, status, data}. Example: live dev1 row (tenant 437, loc=907).","fields":{"data":"array. The page of add-on rows (all rows when limit >= 10000, which MCP sends by default). No ORDER BY on master tenants: order is database order.","data[].id":"number. Add-on (product) id. Pass to get_addon / update_addon / delete_addon.","data[].category_id":"number|null. The add-on's OWN category id (list_categories); INT column, one id. Used by the category_id / cat / brand_id filters. null = uncategorised (such add-ons never match a brand_id filter).","data[].name":"string. The joined branch row's name when non-empty, else the master name. The dashboard shows it title-cased.","data[].description":"string. The joined branch row's description override when non-empty, else the master description.","data[].price":"number. In THIS response a copy of actual_price (VAT-inclusive master price; the handler overwrites the column). The stored ex-VAT price is get_addon.price.","data[].stock":"number. Legacy stock counter on the products row. Not used for add-ons; not needed.","data[].type":"string. Comma-separated CATEGORY NAMES this add-on is offered with, e.g. \"Brewed Coffee,Espresso-Based Drinks\". The POS offers it on a cart line whose product category name is in this list (list_selling_addons). Split on ',' and trim (may be stored with ', '). Dashboard 'Product Type' chips (\"N/A\" when empty).","data[].image":"string. Resolved absolute image URL (or the relative path when the server has no media base URL); \"\" when there is no image.","data[].customer_id":"number. Tenant id (437 on dev1). Always the session tenant; not needed.","data[].location_id":"number|array<number>. Branch ids the add-on is in (from products_locations rows matching loc, deduplicated). A single number when exactly one branch matched, an array otherwise, [] when it has no branch rows. MCP always sends loc = the key's branches, so this is limited to those.","data[].is_addon":"number. 1 = add-on. Always 1 here.","data[].addons":"string. Legacy JSON text, usually \"[]\". Not used for add-ons; not needed.","data[].bundle_items":"string|null. Legacy JSON text (\"[]\" or null). Not needed.","data[].is_disabled":"number. 1 = disabled product. The list filters is_disabled = 0 so it is always 0 there; not settable by these tools.","data[].discount_pct":"number. Per-product discount percent 0–100 (0 = none). Rarely used on add-ons.","data[].modifiers":"null|string. Legacy products.modifiers text column, normally null. Modifier links are in get_addon.modifiers.","data[].is_archive":"number. 1 = archived (hidden from the dashboard list and the POS). 0 unless you listed with is_archive=1.","data[].barcode_type":"string. Barcode symbology label, usually \"\". Not needed for add-ons.","data[].barcode_value":"string|null. Barcode value. Not needed for add-ons.","data[].is_open_price":"number. 1 = cashier types the price at sale time. Normally 0 for add-ons.","data[].is_combo":"number. 1 = combo/deal. Always 0 for add-ons; not needed.","data[].sync_deliverect":"number. 1 = synced to Deliverect. Integration flag; not needed.","data[].sync_otter":"number. 1 = synced to Otter. Integration flag; not needed.","data[].new_cost_date":"string|null. ISO timestamp of the last cost change. Not needed.","data[].product_cost":"number. Cost of goods (materials + recipes), recomputed by the server on every create/update. Informational.","data[].is_weightage_item":"number. 1 = sold by weight. Normally 0 for add-ons.","data[].unit_id":"number|null. Unit of measure id for weighted items. Not needed.","data[].actual_price":"number. Master VAT-inclusive price (products.actual_price). Dashboard 'Price' column. Branch price: override_actual_price / get_addon.location_prices.","data[].old_price":"number|null. Previous price kept by some price-change flows. Not needed.","data[].vat":"number. VAT AMOUNT inside actual_price (actual_price − ex-VAT price), not a percent — e.g. 5 on a 100 add-on. vat ÷ (actual_price − vat) gives the rate used.","data[].localized":"string|null. Legacy localisation blob. Not needed.","data[].base_price":"number|null. Legacy base price. Not needed.","data[].menu_sort_order":"number. Sort position in menus (ascending). 0 = default.","data[].plu":"number|null. PLU code (0/null = none). Not needed.","data[].calories":"number|null. Calories shown on digital menus. Optional info.","data[].is_stock_product":"number|null. Inventory flag. Not needed for add-ons.","data[].is_auto_apply":"number|null. Legacy auto-apply flag. Not needed.","data[].combo_price":"number. Combo price, only meaningful for combos. 0 for add-ons; not needed.","data[].localize_name":"string|null. Name in the second language (\"\" / null = none).","data[].localize_description":"string|null. Description in the second language.","data[].label_id":"number|null. Label id (list_labels). Also part of the name-uniqueness key when the tenant's product_unique_formula is 'name,label_id'.","data[].recipes":"null|string. Legacy products.recipes text column, normally null. Recipe links are in get_addon.recipes.","data[].sku_no":"string. SKU. Auto-generated as prod-<customer_id>-<id> on master tenants when none is sent.","data[].tax_id":"number|null. Tax group id — out of scope; ignore.","data[].preparation_time":"number|null. Preparation minutes for KDS. Optional info.","data[].product_recipe_total":"number. Recipe part of product_cost. Informational.","data[].product_material_total":"number. Material part of product_cost. Informational.","data[].is_overridden":"number. Master: 1 when at least one branch row has a different price or is_available = 0. Informational.","data[].is_deleted":"number. 1 = soft-deleted by delete_addon.","data[].category_sort_order":"number|null. Sort order inside the category on the POS grid (null = 0).","data[].override_location_id":"number|null. Branch id of the first joined products_locations row (with loc: one of the requested branches). The override_* fields below come from this one row.","data[].override_name":"string|null. Branch name override from that row (\"\" / null = uses the master name).","data[].override_description":"string|null. Branch description override from that row.","data[].override_price":"number|null. That branch's ex-VAT price (products_locations.price).","data[].override_actual_price":"number|null. That branch's VAT-inclusive price (products_locations.actual_price) — the price the POS charges at that branch.","data[].image_path":"string. Relative upload path of the image (\"\" = none). Send this back as image on update_addon to keep the image.","data[].web_image":"string. Same relative path as image_path.","data[].channel_price":"array. Per-sales-channel prices from product_channel_price ([] = none).","data[].channel_price[].channel_id":"number. Channel id (list_channels).","data[].channel_price[].channel_name":"string. Channel name.","data[].channel_price[].channel_price":"number. Ex-VAT channel price.","data[].channel_price[].channel_vat":"number. VAT for the channel price.","data[].channel_price[].channel_actual_price":"number. VAT-inclusive channel price.","data[].nameChannels":"object. Wrapper used by the dashboard table.","data[].nameChannels.channels":"array. {channel_id, channel_name} for every channel_price entry. Same data as channel_price; not needed.","data[].nameChannels.channels[].channel_id":"number. Channel id.","data[].nameChannels.channels[].channel_name":"string. Channel name.","data[].total_locations":"number. DISTINCT branches with a products_locations row (all branches, not only loc). An add-on on no branch shows 1 (server quirk) — check location_id = [].","data[].has_recipe":"number. 1 = at least one product_recipes link exists.","total":"number. Total matching add-ons before pagination."},"example":{"data":[{"id":257559,"category_id":null,"name":"Classic Sandwich","description":"","price":100,"stock":0,"type":"Alternate Options,Brewed Coffee,Espresso-Based Drinks,Iced & Cold Coffees,Specialty & Flavored Coffees","image":"","customer_id":437,"location_id":907,"is_addon":1,"addons":"[]","bundle_items":"[]","is_disabled":0,"discount_pct":0,"modifiers":null,"is_archive":0,"barcode_type":"","barcode_value":"","is_open_price":0,"is_combo":0,"sync_deliverect":0,"sync_otter":0,"new_cost_date":null,"product_cost":0,"is_weightage_item":0,"unit_id":null,"actual_price":100,"old_price":null,"vat":5,"localized":null,"base_price":null,"menu_sort_order":0,"plu":0,"calories":null,"is_stock_product":null,"is_auto_apply":null,"combo_price":0,"localize_name":"","localize_description":"","label_id":null,"recipes":null,"sku_no":"prod-437-257559","tax_id":null,"preparation_time":0,"product_recipe_total":0,"product_material_total":0,"is_overridden":0,"is_deleted":0,"category_sort_order":null,"override_location_id":907,"override_name":"Classic Sandwich","override_description":"","override_price":95,"override_actual_price":100,"image_path":"","web_image":"","channel_price":[],"nameChannels":{"channels":[]},"total_locations":4,"has_recipe":0}],"total":7},"master_rules":["The dashboard Add-ons page does NOT call this endpoint: it calls GET /api/products?addon=1 with loc (\"null\" = all branches on first open; one id, or a csv when several branches are ticked, of which the server effectively uses only the first), cat = the Category dropdown's NAME (substring match on the add-on's own category, \"All\" = none), brand_id (Brand dropdown), adm=1, page_limit (10 by default; 5/10/25) and current_page. It has no search box and no archived view. To reproduce it here send loc (one id), cat (exact name) or category_id, brand_id; omit limit to get every row.","Each dashboard row shows name (title-cased), Price = actual_price, Product Type = type split on ',' as chips, plus Edit (→ get_addon) and Delete (confirm → delete_addon) buttons. The page carries a deprecation banner pointing to Modifiers, and its 'create add-on' route redirects to Modifiers — new add-ons can only be made with create_addon.","What the POS actually offers: an add-on appears on a cart line when its type contains the parent product's category name (list_selling_products[].type), it has a branch row at the session branch with is_available = 1 — see list_selling_addons for the branch view (includes is_sold_out). This list ignores is_available, so check get_addon.location_prices before saying it sells at a branch.","To find add-ons offered with a category, fetch the list and keep rows whose type (split on ',' and trimmed) contains the exact category name — the category_id / cat / brand_id filters look at the add-on's OWN category, not type.","Soft-deleted add-ons are hidden here; the dashboard's own list endpoint does not filter is_deleted, so a deleted add-on can still appear in the dashboard."]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id or csv of ids (list_locations)."},"k":{"type":"string","description":"Name search, case-insensitive substring (LIKE %k%) on the MASTER name (not branch names)."},"category_id":{"type":"string","description":"Category id or csv of ids (list_categories): keeps add-ons whose OWN category_id is one of them."},"cat":{"type":"string","description":"Category NAME or csv of names, exact match (case-insensitive, trimmed) on the add-on's OWN category; used only when category_id is omitted/All."},"brand_id":{"type":"number","description":"Brand id (list_brands): add-ons whose own category is in a group linked to the brand (brands_groups_relation → group_categories)."},"label_id":{"type":"number","description":"Label id (list_labels): exact products.label_id match."},"channel_id":{"type":"number","description":"Channel id (list_channels): keeps add-ons that have a channel price row for it."},"is_archive":{"type":"number","description":"0 = active add-ons (default), 1 = archived ones (never both)."},"page":{"type":"number","description":"1-based page; used only when limit < 10000."},"limit":{"type":"number","description":"Rows per page."}},"required":[]},"module":"addons","safety":"read"},{"name":"get_addon","description":"GET /api/products/:id. Why: Load one add-on with everything update_addon needs: master prices, offer list (type), image path, per-branch price and availability (location_prices) and modifier/recipe/channel links. Call it before every update_addon (update rewrites the whole record). Takes an id from list_addons. Catalog management tool — what a branch's POS offers is list_selling_addons; branch ids/names come from list_locations, channel ids from list_channels. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_addon\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/products/:id","why":"Load one add-on with everything update_addon needs: master prices, offer list (type), image path, per-branch price and availability (location_prices) and modifier/recipe/channel links. Call it before every update_addon (update rewrites the whole record). Takes an id from list_addons. Catalog management tool — what a branch's POS offers is list_selling_addons; branch ids/names come from list_locations, channel ids from list_channels.","response":{"description":"200 → a one-element ARRAY [add-on] (take [0]): SELECT * FROM products WHERE id = :id plus location_id (all branch ids), parsed addons/bundle_items, image fields, modifiers, channel_price, materials, location_prices and recipes. The server does NOT check tenant, is_addon, is_archive or is_deleted — verify is_addon = 1 and is_deleted = 0 yourself. Unknown id → 404 {message:\"Product not found\"} (live-verified); DB error → 500 {error:\"Failed to fetch product\", detail}; through MCP an HTTP error arrives as {error, status, data}. Example: live dev1 add-on 257559.","fields":{"[]":"array with exactly ONE product object. 404 → {message:\"Product not found\"}; 500 → {error, detail}.","[].id":"number. Add-on (product) id. Pass to get_addon / update_addon / delete_addon.","[].category_id":"number|null. The add-on's OWN category id (list_categories); INT column, one id. Used by the category_id / cat / brand_id filters. null = uncategorised (such add-ons never match a brand_id filter).","[].name":"string. Master name, replaced by the newest non-empty branch-name override (restricted to loc when sent).","[].description":"string. Master description (branch overrides are not applied here).","[].price":"number. Stored ex-VAT master price (actual_price − vat). Unlike list_addons, NOT a copy of actual_price. Resend it as price on update_addon.","[].stock":"number. Legacy opening-stock column. Dashboard 'Stock' field; resend as stock.","[].type":"string. Comma-separated CATEGORY NAMES this add-on is offered with. The POS offers it on a cart line whose product category name is in this list (list_selling_addons). Resend on update_addon (omitted → rebuilt from category_id, or cleared). The dashboard maps these names to active categories ignoring case and spaces; names that match no active category are silently dropped on its next save.","[].image":"string. Absolute image URL (\"\" = none). Do NOT resend as image; use image_path.","[].customer_id":"number. Tenant id (437 on dev1). Always the session tenant; not needed.","[].location_id":"number|array<number>. Every branch id with a products_locations row (NOT limited to loc, not deduplicated). One number when exactly one row, array otherwise ([] = in no branch).","[].is_addon":"number. 1 = add-on. Always 1 here.","[].addons":"array. Legacy JSON parsed, usually []. Not needed.","[].bundle_items":"array. Legacy JSON parsed, usually []. Not needed.","[].is_disabled":"number. 1 = disabled product. The list filters is_disabled = 0 so it is always 0 there; not settable by these tools.","[].discount_pct":"number. Per-product discount percent 0–100 (0 = none). Rarely used on add-ons.","[].modifiers":"array. Modifier groups linked to the add-on (product_modifiers), ordered by location_id, modifier_id. Usually [] for add-ons.","[].is_archive":"number. 1 = archived (hidden from the dashboard list and the POS). 0 unless you listed with is_archive=1.","[].barcode_type":"string. Product-code symbology (\"\", \"EAN-13\", \"QR Code\"). Dashboard 'Product Code'; resend as barcode_type.","[].barcode_value":"string|null. Barcode value. Resend to keep it.","[].is_open_price":"number. 1 = cashier types the price at sale time. Normally 0 for add-ons.","[].is_combo":"number. 1 = combo/deal. Always 0 for add-ons; not needed.","[].sync_deliverect":"number. 1 = synced to Deliverect. Integration flag; not needed.","[].sync_otter":"number. 1 = synced to Otter. Integration flag; not needed.","[].new_cost_date":"string|null. ISO timestamp of the last cost change. Not needed.","[].product_cost":"number. Cost of goods (materials + recipes), recomputed by the server on every create/update. Informational.","[].is_weightage_item":"number. 1 = sold by weight. Normally 0 for add-ons.","[].unit_id":"number|null. Unit of measure id for weighted items. Not needed.","[].actual_price":"number. Master VAT-inclusive price. Resend as actual_price on update_addon (omitted → 0). The dashboard shows it in 'Actual Price'.","[].old_price":"number|null. Previous price kept by some price-change flows. Not needed.","[].vat":"number. VAT AMOUNT inside actual_price (not a percent), e.g. 5 on 100. Resend on update_addon.","[].localized":"string|null. Legacy localisation blob. Not needed.","[].base_price":"number|null. Legacy base price. Not needed.","[].menu_sort_order":"number. Sort position in menus (ascending). 0 = default.","[].plu":"number|null. PLU code (0/null = none). Resend as plu to keep it.","[].calories":"number|null. Calories shown on digital menus. Optional info.","[].is_stock_product":"number|null. Inventory flag. Not needed for add-ons.","[].is_auto_apply":"number|null. Legacy auto-apply flag. Not needed.","[].combo_price":"number. Combo price, only meaningful for combos. 0 for add-ons; not needed.","[].localize_name":"string|null. Name in the second language. Resend to keep it.","[].localize_description":"string|null. Description in the second language.","[].label_id":"number|null. Label id (list_labels). Also part of the name-uniqueness key when the tenant's product_unique_formula is 'name,label_id'.","[].recipes":"array. Recipe links (product_recipes JOIN recipes). [] for most add-ons.","[].sku_no":"string. SKU. Auto-generated as prod-<customer_id>-<id> on master tenants when none is sent.","[].tax_id":"number|null. Tax id — out of scope; ignore (update_addon stores NULL).","[].preparation_time":"number|null. Preparation minutes for KDS. Optional info.","[].product_recipe_total":"number. Recipe part of product_cost. Informational.","[].product_material_total":"number. Material part of product_cost. Informational.","[].is_overridden":"number. Master: 1 when at least one branch row has a different price or is_available = 0. Informational.","[].is_deleted":"number. 1 = soft-deleted by delete_addon.","[].category_sort_order":"number|null. Sort order inside the category on the POS grid (null = 0).","[].modifiers[].id":"number. product_modifiers row id.","[].modifiers[].modifier_id":"number. Modifier group id (list_modifiers).","[].modifiers[].max_allowed":"number. Max items selectable (0 = no max).","[].modifiers[].min_allowed":"number. Min items required.","[].modifiers[].priority":"number. Display order of the group.","[].modifiers[].location_id":"number|null. Branch this link applies to; null = all branches.","[].modifiers[].customer_id":"number|null. Tenant id; not needed.","[].modifiers[].selection_type":"string. \"multiple\" (default) or \"single\".","[].modifiers[].auto_selected_modifier_item_ids":"array<number>. Modifier item ids pre-selected by default.","[].modifiers[].mandatory_modifier_item_ids":"array<number>. Modifier item ids that are mandatory.","[].modifiers[].unavailable_modifier_item_ids":"array<number>. Modifier item ids hidden for this product.","[].modifiers[].modifier_items_order":"array<number>. Display order of the group's items.","[].recipes[].id":"number. Recipe id.","[].recipes[].name":"string. Recipe name.","[].recipes[].yield_qty":"number. Recipe yield quantity.","[].recipes[].mode":"string. Recipe mode (phantom / stockable).","[].recipes[].recipe_id":"number. Same as id.","[].recipes[].product_id":"number. This add-on id.","[].recipes[].portion_qty":"number. Portion of the recipe used per add-on.","[].recipes[].unit":"string|null. Unit of portion_qty.","[].recipes[].active_from":"string|null. ISO date the link starts.","[].recipes[].active_to":"string|null. ISO date the link ends.","[].recipes[].qty_used":"number. Quantity consumed per sale.","[].image_path":"string. Relative upload path (\"\" = none). Send it back as image on update_addon or the image is cleared.","[].web_image":"string. Same relative path as image_path.","[].channel_price":"array. Per-channel prices ([] = none).","[].channel_price[].channel_id":"number. Channel id (list_channels).","[].channel_price[].channel_name":"string. Channel name.","[].channel_price[].channel_price":"number. Ex-VAT channel price.","[].channel_price[].channel_vat":"number. Channel VAT.","[].channel_price[].channel_actual_price":"number. VAT-inclusive channel price.","[].materials":"array. Raw-material links (non-master feature, out of scope). [] for most add-ons. update_addon deletes them.","[].materials[].id":"number. product_materials row id.","[].materials[].name":"string. Material name.","[].materials[].product_id":"number. This add-on id.","[].materials[].material_id":"number. Material id (list_materials).","[].materials[].qty":"number. Quantity used per sale.","[].materials[].customer_id":"number. Tenant id; not needed.","[].materials[].location_id":"number|null. Branch the link was saved for.","[].materials[].price":"number. Material price saved on the link.","[].materials[].unit":"string|null. Unit of qty.","[].materials[].wastage":"number. Wastage percent.","[].materials[].materials_price":"number. Current material avg_price.","[].materials[].conversionrate":"number. Purchase→ingredient unit conversion (1 when null).","[].materials[].cost":"number. qty × avg_price / conversionrate.","[].materials[].ingredientunit":"string|null. Ingredient unit name.","[].location_prices":"array. One entry per branch row (products_locations JOIN locations). THIS is the branch list + per-branch price/availability; send it back (edited) as location_prices on update_addon.","[].location_prices[].location_id":"number. Branch id.","[].location_prices[].location_name":"string. Branch name.","[].location_prices[].location_price":"number|null. Branch ex-VAT/stored price; null when the column is NULL (live: branch 908 of add-on 257559). Use location_actual_price.","[].location_prices[].location_actual_price":"number. Branch VAT-inclusive price (falls back to location_price) — what the POS charges there. Send it as location_price on update_addon.","[].location_prices[].is_available":"boolean. false = switched off at that branch (the POS hides it there)."},"example":[{"id":257559,"category_id":null,"name":"Classic Sandwich","description":"","price":95,"stock":0,"type":"Alternate Options,Brewed Coffee,Espresso-Based Drinks,Iced & Cold Coffees,Specialty & Flavored Coffees","image":"","customer_id":437,"location_id":[908,907,910,1131],"is_addon":1,"addons":[],"bundle_items":[],"is_disabled":0,"discount_pct":0,"modifiers":[],"is_archive":0,"barcode_type":"","barcode_value":"","is_open_price":0,"is_combo":0,"sync_deliverect":0,"sync_otter":0,"new_cost_date":null,"product_cost":0,"is_weightage_item":0,"unit_id":null,"actual_price":100,"old_price":null,"vat":5,"localized":null,"base_price":null,"menu_sort_order":0,"plu":0,"calories":null,"is_stock_product":null,"is_auto_apply":null,"combo_price":0,"localize_name":"","localize_description":"","label_id":null,"recipes":[],"sku_no":"prod-437-257559","tax_id":null,"preparation_time":0,"product_recipe_total":0,"product_material_total":0,"is_overridden":0,"is_deleted":0,"category_sort_order":null,"image_path":"","web_image":"","channel_price":[],"materials":[],"location_prices":[{"location_id":908,"location_name":"Johar Town","location_price":null,"location_actual_price":100,"is_available":true},{"location_id":907,"location_name":"Default location","location_price":95,"location_actual_price":100,"is_available":true},{"location_id":910,"location_name":"Bahira Town","location_price":95,"location_actual_price":100,"is_available":true},{"location_id":1131,"location_name":"Test","location_price":95,"location_actual_price":100,"is_available":true}]}],"master_rules":["The dashboard edit page (/dashboard/products/addon/edit?mode=edit&id=<id>) calls this with the id only and uses [0]. It shows Name, Description, Actual Price, VAT and Price without VAT (read-only), Discount %, Tax Group/Tax, Stock, Product Code (barcode_type), SKU (read-only), Categories (type names matched to active categories), Locations (location_id), Ingredients (materials) and the image.","Before update_addon copy these keys from [0]: name, description, actual_price, price, vat, type, category_id, image_path (send as image), discount_pct, stock, barcode_type, barcode_value, sku_no, label_id, localize_name, localize_description, preparation_time, plu, unit_id, is_open_price, is_weightage_item, is_archive, channel_price, modifiers (modifier_id, max_allowed, min_allowed, priority, selection_type and the *_ids / modifier_items_order lists), recipes (recipe_id, portion_qty, unit, qty_used) and location_prices as [{location_id, location_price: location_actual_price, is_available}]. Change only what the user asked for.","Only send schema keys to update_addon (MCP drops the rest): keys such as image_path or location_name are not products columns and would fail the server call.","Branch facts: location_prices lists every branch row (is_available false = switched off there). Sold-out (is_sold_out) is not returned here — use list_selling_addons or list_sold_out_products for the session branch.","When selling, an add-on line in sync_invoice has is_addon = 1, addon_linked_with = the parent line's identifier and the branch price (list_selling_addons actual_price)."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Add-on id (list_addons data[].id)."},"loc":{"type":"string","description":"Branch id or csv."}},"required":["id"]},"module":"addons","safety":"read"},{"name":"create_addon","description":"POST /api/products. Why: Create a new add-on (products row with is_addon = 1). Set type to the parent category NAMES it is offered with (list_categories) and location_prices to the branches/prices it sells at (list_locations) — without location_prices a master add-on is in no branch and the POS never offers it. Channel prices: price_level (list_channels ids). Produces productId for get_addon / update_addon. Afterwards it shows in list_addons and, at those branches, in list_selling_addons. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_addon\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/products","why":"Create a new add-on (products row with is_addon = 1). Set type to the parent category NAMES it is offered with (list_categories) and location_prices to the branches/prices it sells at (list_locations) — without location_prices a master add-on is in no branch and the POS never offers it. Channel prices: price_level (list_channels ids). Produces productId for get_addon / update_addon. Afterwards it shows in list_addons and, at those branches, in list_selling_addons.","response":{"description":"Success: HTTP 200 {success:true, message:\"Product created successfully\", productId}. Errors: 400 {err:\"Name can't be blank\"}; 400 {success:false, err:\"Product with name \\\"<name>\\\" already exists\", message:\"A product with this name already exists. Please use a different name.\"} (or \"…name and label…\" when the tenant's product_unique_formula is name,label_id); 400 {success:false, message:\"Only phantom recipes can be linked to products. \\\"<recipe>\\\" is stockable.\", error:<same>}; 500 {success:false, message:\"Database error\", error:<SQL message>} (e.g. \"Recipe <id> was not found for this customer\"). Through MCP an HTTP error arrives as {error, status, data:<body above>}. Not transactional: a failure after the insert (modifier/channel/recipe step) leaves the new row. On master the SKU is generated as prod-<customer_id>-<id> when sku_no is blank. Example derived from code.","fields":{"success":"boolean. true on success; false on duplicate-name / recipe / DB errors (absent on the blank-name 400).","message":"string. \"Product created successfully\" on success; otherwise the human-readable reason (duplicate name text, recipe text, or \"Database error\").","productId":"number. New add-on id — pass to get_addon / update_addon / delete_addon. Success only.","err":"string. Error text on 400 blank name (\"Name can't be blank\") and 400 duplicate name (\"Product with name \\\"…\\\" already exists\").","error":"string. Underlying error message on 400 recipe and 500 responses."},"example":{"success":true,"message":"Product created successfully","productId":271199},"master_rules":["The dashboard no longer creates add-ons: its create route redirects to Modifiers. Its (now edit-only) form would send name, description, actual_price, vat and price derived from it, discount_pct, stock, barcode_type, type = chosen category names joined with ',', is_addon true, location_id = ticked branch ids as csv and location_prices: [] — which on master leaves a new add-on in NO branch. To create one that sells, send the same fields plus location_prices = [{location_id, location_price: <VAT-inclusive price>, is_available: 1}] for every branch.","Required by the dashboard form and needed by the POS: name, actual_price, at least one category name in type, at least one branch. Use exact category names from list_categories / list_selling_categories — the POS matches type against the parent product's category name.","Price maths: price = actual_price − vat, vat = actual_price × rate rounded to 2 decimals. The dashboard hard-codes a 5% rate; take the tenant's rate from existing add-ons (vat ÷ price of a list_addons row) or ask the user.","Create only with a unique name (list_addons k=<name>, also check list_products) to avoid the 400 duplicate-name error.","After creating, call get_addon(productId) and confirm location_prices lists the intended branches; sold-out at a branch is set with update_product_availability (it accepts add-on ids)."]},"parameters":{"type":"object","properties":{"name":{"type":"string","description":"Required."},"description":{"type":"string","description":"Optional description."},"actual_price":{"type":"number","description":"VAT-inclusive selling price — the price the POS charges."},"price":{"type":"number","description":"Ex-VAT price = actual_price − vat."},"vat":{"type":"number","description":"VAT AMOUNT inside actual_price (not a percent), e.g."},"type":{"type":"string","description":"Comma-separated CATEGORY NAMES of the parent products this add-on is offered with, e.g."},"category_id":{"type":"number","description":"The add-on's OWN category id (list_categories)."},"image":{"type":"string","description":"Image relative upload path or URL; normalised to the relative path."},"web_image":{"type":"string","description":"Alternative image field; wins over image when both are sent (not a column)."},"is_addon":{"type":"string","description":"Add-on flag."},"location_id":{"type":"array","items":{"type":"number","description":"Branch id (list_locations)."},"description":"Branch ids (list_locations)."},"location_prices":{"type":"array","items":{"type":"object","properties":{"location_id":{"type":"number","description":"Branch id (list_locations)."},"location_price":{"type":"number","description":"Branch price."},"location_actual_price":{"type":"number","description":"VAT-inclusive branch price."},"is_available":{"type":"string","description":"false/0 = switched off at that branch (the POS hides it there)."}},"required":["location_id"],"description":""},"description":"Branch rows (products_locations) — the ONLY way to put a new add-on in branches on master tenants; without it the add-on is in no branch and the POS never show…"},"is_archive":{"type":"string","description":"1 = archived (hidden from list_addons and the POS)."},"is_open_price":{"type":"string","description":"1 = cashier enters the price at sale time."},"is_weightage_item":{"type":"string","description":"1 = sold by weight."},"discount_pct":{"type":"number","description":"Per-product discount percent 0–100; anything else or omitted → 0 (also on update)."},"sku_no":{"type":"string","description":"SKU, trimmed."},"label_id":{"type":"number","description":"Label id (list_labels)."},"menu_sort_order":{"type":"number","description":"Menu sort position (ascending)."},"localize_name":{"type":"string","description":"Name in the second (localised) language."},"localize_description":{"type":"string","description":"Description in the second language."},"calories":{"type":"number","description":"Calories for digital menus."},"preparation_time":{"type":"number","description":"Preparation minutes for KDS."},"addons":{"type":"array","items":{"type":"number","description":"Legacy entry; keep the list empty."},"description":"Legacy JSON list stored as text."},"bundle_items":{"type":"array","items":{"type":"number","description":"Legacy entry; keep the list empty."},"description":"Legacy bundle list stored as text."},"modifiers":{"type":"array","items":{"type":"object","properties":{"modifier_id":{"type":"number","description":"Modifier group id (list_modifiers)."},"max_allowed":{"type":"number","description":"Max items selectable (0 = no max)."},"min_allowed":{"type":"number","description":"Min items required."},"priority":{"type":"number","description":"Display order."},"selection_type":{"type":"string","description":"\"multiple\" or \"single\"."},"auto_selected_modifier_item_ids":{"type":"array","items":{"type":"number","description":"Modifier item id."},"description":"Items pre-selected by default."},"mandatory_modifier_item_ids":{"type":"array","items":{"type":"number","description":"Modifier item id."},"description":"Items that are mandatory."},"unavailable_modifier_item_ids":{"type":"array","items":{"type":"number","description":"Modifier item id."},"description":"Items hidden for this add-on."},"modifier_items_order":{"type":"array","items":{"type":"number","description":"Modifier item id."},"description":"Item display order; omitted → the modifier's current order."},"id":{"type":"number","description":"Existing product_modifiers row id to overwrite."}},"required":["modifier_id"],"description":""},"description":"Modifier groups to link (rare for add-ons)."},"modifiers_by_location":{"type":"string","description":"update_addon only."},"recipes":{"type":"array","items":{"type":"object","properties":{"recipe_id":{"type":"number","description":"Recipe id (list_recipes)."},"portion_qty":{"type":"number","description":"Portion of the recipe per sale."},"qty_used":{"type":"number","description":"Quantity consumed per sale."},"unit":{"type":"string","description":"Unit of portion_qty."},"total_cost":{"type":"number","description":"Recipe cost added to product_cost."}},"required":["recipe_id"],"description":""},"description":"Recipe links (rare for add-ons)."},"price_level":{"type":"array","items":{"type":"object","properties":{"channel_id":{"type":"number","description":"Channel id (list_channels / list_selling_channels)."},"channel_name":{"type":"string","description":"Channel name stored with the price."},"channel_price":{"type":"number","description":"Ex-VAT channel price (0 when omitted)."},"channel_vat":{"type":"number","description":"VAT amount for the channel price."},"channel_actual_price":{"type":"number","description":"VAT-inclusive channel price — what the POS charges on that channel (0 when omitted)."}},"required":["channel_id"],"description":""},"description":"create_addon only: per-channel prices."},"channel_price":{"type":"array","items":{"type":"object","properties":{"channel_id":{"type":"number","description":"Channel id (list_channels / list_selling_channels)."},"channel_name":{"type":"string","description":"Channel name stored with the price."},"channel_price":{"type":"number","description":"Ex-VAT channel price (0 when omitted)."},"channel_vat":{"type":"number","description":"VAT amount for the channel price."},"channel_actual_price":{"type":"number","description":"VAT-inclusive channel price — what the POS charges on that channel (0 when omitted)."}},"required":["channel_id"],"description":""},"description":"update_addon only: per-channel prices."},"stock":{"type":"number","description":"Legacy opening-stock column (not live inventory)."},"barcode_type":{"type":"string","enum":["","EAN-13","QR Code"],"description":"Product-code symbology."},"barcode_value":{"type":"string","description":"Barcode value."},"unit_id":{"type":"number","description":"Unit of measure id for weighted items."},"plu":{"type":"number","description":"PLU code (INT column) for scales/integrations."}},"required":["name"]},"module":"addons","safety":"write"},{"name":"update_addon","description":"PUT /api/products/:id. Why: Edit an add-on: prices, name, offer list (type = category names from list_categories), branches and per-branch price/availability (location_prices, list_locations ids), channel prices (channel_price, list_channels ids), image. FULL REWRITE — read get_addon first and send every field back. Archive/unarchive only: archive_product is lighter. Sold-out at a branch: update_product_availability, not this tool. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_addon\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/products/:id","why":"Edit an add-on: prices, name, offer list (type = category names from list_categories), branches and per-branch price/availability (location_prices, list_locations ids), channel prices (channel_price, list_channels ids), image. FULL REWRITE — read get_addon first and send every field back. Archive/unarchive only: archive_product is lighter. Sold-out at a branch: update_product_availability, not this tool.","response":{"description":"Success: HTTP 200 {success:true, message:\"Product updated successfully\", product:{id, …request body as received plus customer_id}} — an echo of what you sent, NOT the stored row (call get_addon to see the result). Errors: 404 {success:false, err:\"Product not found\", message:\"The product you are updating was not found.\"}; 400 {success:false, err:\"Product with name \\\"<name>\\\" already exists\", message} duplicate name; 400 {error:\"Only phantom recipes can be linked to products. …\"}; 500 {error:<SQL message>} (e.g. \"Unknown column '<key>' in 'field list'\" when a non-column key is sent). Through MCP an HTTP error arrives as {error, status, data}. The server does not check that the id belongs to your tenant. Side effects on master: branch rows replaced (location_prices, else location_id) with is_sold_out reset, material links deleted, recipe links rebuilt, deals_locations rows rewritten, is_overridden recomputed. Example derived from code.","fields":{"success":"boolean. true on success; false on 404 / duplicate name (absent on {error} responses).","message":"string. \"Product updated successfully\", or the reason on 404 / duplicate name.","product":"object. Echo of every body key you sent, unchanged except image/web_image normalised to relative paths, plus id and customer_id. Not re-read from the DB.","product.id":"string. The :id path value as a STRING, e.g. \"257559\".","product.customer_id":"number. Session tenant id added by the server.","err":"string. Error text on 404 (\"Product not found\") and 400 duplicate name.","error":"string. Error message on 400 recipe and 500 responses."},"example":{"success":true,"message":"Product updated successfully","product":{"id":"257559","name":"Classic Sandwich","description":"","actual_price":100,"price":95,"vat":5,"type":"Alternate Options,Brewed Coffee","image":"","is_addon":1,"location_id":[907],"location_prices":[{"location_id":907,"location_price":100,"is_available":1}],"customer_id":437}},"master_rules":["The dashboard edit form (Save changes) sends PUT with {id, …every key of get_product [0], the edited fields, type = selected category NAMES joined with ',', location_id = ticked branch ids as a csv string, location_prices: [], tax_group_id, tax_id, group_thumbnail}. On master that deletes every branch row and recreates the ticked branches at actual_price with is_available = 1 (branch price overrides, switched-off branches and sold-out flags are lost). To make the same edit without that loss, send location_prices copied from get_addon.","Validation the dashboard enforces: name required, actual_price required (digits with one '.'), at least one category in type, at least one branch. Apply the same before calling.","Price maths: when actual_price changes, also send vat = actual_price × rate (2 decimals) and price = actual_price − vat, taking the rate from the add-on's current vat ÷ price. When location_prices is sent, update each branch's location_price too if the branch should follow the new price.","Keep the image by sending image = get_addon.image_path (the dashboard keeps it through web_image; its upload box writes group_thumbnail, which the server discards).","Reset on every call when omitted: price/actual_price → 0, type → from category_id or \"\" (add-on disappears from every cart line), image → \"\", discount_pct → 0, is_archive → 0 (un-archives), is_open_price/is_weightage_item → 0, tax_id → NULL, recipe links removed. Send them all from get_addon.","Removing a branch = send location_prices without it; switching it off but keeping it = is_available 0. Hiding at one branch for today = update_product_availability (sold-out) instead."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Add-on id (list_addons / get_addon)."},"name":{"type":"string","description":"Required."},"description":{"type":"string","description":"Optional description."},"actual_price":{"type":"number","description":"VAT-inclusive selling price — the price the POS charges."},"price":{"type":"number","description":"Ex-VAT price = actual_price − vat."},"vat":{"type":"number","description":"VAT AMOUNT inside actual_price (not a percent), e.g."},"type":{"type":"string","description":"Comma-separated CATEGORY NAMES of the parent products this add-on is offered with, e.g."},"category_id":{"type":"number","description":"The add-on's OWN category id (list_categories)."},"image":{"type":"string","description":"Image relative upload path or URL; normalised to the relative path."},"web_image":{"type":"string","description":"Alternative image field; wins over image when both are sent (not a column)."},"is_addon":{"type":"string","description":"Add-on flag."},"location_id":{"type":"array","items":{"type":"number","description":"Branch id (list_locations)."},"description":"Branch ids (list_locations)."},"location_prices":{"type":"array","items":{"type":"object","properties":{"location_id":{"type":"number","description":"Branch id (list_locations)."},"location_price":{"type":"number","description":"Branch price."},"location_actual_price":{"type":"number","description":"VAT-inclusive branch price."},"is_available":{"type":"string","description":"false/0 = switched off at that branch (the POS hides it there)."}},"required":["location_id"],"description":""},"description":"Branch rows (products_locations) — the ONLY way to put a new add-on in branches on master tenants; without it the add-on is in no branch and the POS never show…"},"is_archive":{"type":"string","description":"1 = archived (hidden from list_addons and the POS)."},"is_open_price":{"type":"string","description":"1 = cashier enters the price at sale time."},"is_weightage_item":{"type":"string","description":"1 = sold by weight."},"discount_pct":{"type":"number","description":"Per-product discount percent 0–100; anything else or omitted → 0 (also on update)."},"sku_no":{"type":"string","description":"SKU, trimmed."},"label_id":{"type":"number","description":"Label id (list_labels)."},"menu_sort_order":{"type":"number","description":"Menu sort position (ascending)."},"localize_name":{"type":"string","description":"Name in the second (localised) language."},"localize_description":{"type":"string","description":"Description in the second language."},"calories":{"type":"number","description":"Calories for digital menus."},"preparation_time":{"type":"number","description":"Preparation minutes for KDS."},"addons":{"type":"array","items":{"type":"number","description":"Legacy entry; keep the list empty."},"description":"Legacy JSON list stored as text."},"bundle_items":{"type":"array","items":{"type":"number","description":"Legacy entry; keep the list empty."},"description":"Legacy bundle list stored as text."},"modifiers":{"type":"array","items":{"type":"object","properties":{"modifier_id":{"type":"number","description":"Modifier group id (list_modifiers)."},"max_allowed":{"type":"number","description":"Max items selectable (0 = no max)."},"min_allowed":{"type":"number","description":"Min items required."},"priority":{"type":"number","description":"Display order."},"selection_type":{"type":"string","description":"\"multiple\" or \"single\"."},"auto_selected_modifier_item_ids":{"type":"array","items":{"type":"number","description":"Modifier item id."},"description":"Items pre-selected by default."},"mandatory_modifier_item_ids":{"type":"array","items":{"type":"number","description":"Modifier item id."},"description":"Items that are mandatory."},"unavailable_modifier_item_ids":{"type":"array","items":{"type":"number","description":"Modifier item id."},"description":"Items hidden for this add-on."},"modifier_items_order":{"type":"array","items":{"type":"number","description":"Modifier item id."},"description":"Item display order; omitted → the modifier's current order."},"id":{"type":"number","description":"Existing product_modifiers row id to overwrite."}},"required":["modifier_id"],"description":""},"description":"Modifier groups to link (rare for add-ons)."},"modifiers_by_location":{"type":"string","description":"update_addon only."},"recipes":{"type":"array","items":{"type":"object","properties":{"recipe_id":{"type":"number","description":"Recipe id (list_recipes)."},"portion_qty":{"type":"number","description":"Portion of the recipe per sale."},"qty_used":{"type":"number","description":"Quantity consumed per sale."},"unit":{"type":"string","description":"Unit of portion_qty."},"total_cost":{"type":"number","description":"Recipe cost added to product_cost."}},"required":["recipe_id"],"description":""},"description":"Recipe links (rare for add-ons)."},"price_level":{"type":"array","items":{"type":"object","properties":{"channel_id":{"type":"number","description":"Channel id (list_channels / list_selling_channels)."},"channel_name":{"type":"string","description":"Channel name stored with the price."},"channel_price":{"type":"number","description":"Ex-VAT channel price (0 when omitted)."},"channel_vat":{"type":"number","description":"VAT amount for the channel price."},"channel_actual_price":{"type":"number","description":"VAT-inclusive channel price — what the POS charges on that channel (0 when omitted)."}},"required":["channel_id"],"description":""},"description":"create_addon only: per-channel prices."},"channel_price":{"type":"array","items":{"type":"object","properties":{"channel_id":{"type":"number","description":"Channel id (list_channels / list_selling_channels)."},"channel_name":{"type":"string","description":"Channel name stored with the price."},"channel_price":{"type":"number","description":"Ex-VAT channel price (0 when omitted)."},"channel_vat":{"type":"number","description":"VAT amount for the channel price."},"channel_actual_price":{"type":"number","description":"VAT-inclusive channel price — what the POS charges on that channel (0 when omitted)."}},"required":["channel_id"],"description":""},"description":"update_addon only: per-channel prices."},"stock":{"type":"number","description":"Legacy opening-stock column (not live inventory)."},"barcode_type":{"type":"string","enum":["","EAN-13","QR Code"],"description":"Product-code symbology."},"barcode_value":{"type":"string","description":"Barcode value."},"unit_id":{"type":"number","description":"Unit of measure id for weighted items."},"plu":{"type":"number","description":"PLU code (INT column) for scales/integrations."}},"required":["id","name"]},"module":"addons","safety":"write"},{"name":"delete_addon","description":"DELETE /api/products/:id. Why: Soft-delete an add-on (is_deleted = 1): it disappears from list_addons, list_selling_addons and the POS, but branch rows, links and the row stay (get_addon still returns it with is_deleted = 1); there is no undelete tool. To hide it reversibly use archive_product (or update_addon with is_archive = 1). Takes an id from list_addons. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_addon\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/products/:id","why":"Soft-delete an add-on (is_deleted = 1): it disappears from list_addons, list_selling_addons and the POS, but branch rows, links and the row stay (get_addon still returns it with is_deleted = 1); there is no undelete tool. To hide it reversibly use archive_product (or update_addon with is_archive = 1). Takes an id from list_addons.","response":{"description":"200 {status:true, message:\"Product deleted successfully\"} (sent as text; parse as JSON). Always true: the server does not check that the id exists, is an add-on, or belongs to your tenant. A DB error is unhandled (the request hangs / connection error; through MCP {error}). Example derived from code.","fields":{"status":"boolean. Always true (no error body exists).","message":"string. \"Product deleted successfully\"."},"example":{"status":true,"message":"Product deleted successfully"},"master_rules":["The dashboard deletes from the add-on list: trash icon → confirmation dialog → DELETE /api/products/:id, then a toast with message. Ask the user to confirm the add-on name before calling, like the dashboard does.","Check the id with get_addon first (is_addon = 1, is_deleted = 0): the server deletes any product id without checking.","Existing orders keep their add-on lines; deletion only stops future sales. If the user may want it back, archive instead (archive_product)."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Add-on id (list_addons data[].id)."}},"required":["id"]},"module":"addons","safety":"destructive"},{"name":"list_labels","description":"GET /api/label. Why: Every product label of the tenant with its branch list — the dashboard Products → Labels page and the source of label_id for create_product / update_product, the list_products label_id filter and the labels shown on sold lines (sync_invoice / list_orders_history items[].label_id). Search by name with k, filter by branch with loc (ids from list_locations). Get ids here for get_label / update_label / delete_label. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_labels\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/label","why":"Every product label of the tenant with its branch list — the dashboard Products → Labels page and the source of label_id for create_product / update_product, the list_products label_id filter and the labels shown on sold lines (sync_invoice / list_orders_history items[].label_id). Search by name with k, filter by branch with loc (ids from list_locations). Get ids here for get_label / update_label / delete_label.","response":{"description":"200 → { data }. data = every label row of the session tenant (SELECT * FROM label, no ORDER BY — rows come back in id order in practice) after the k and loc filters, with location_id replaced by the label's branch list. Errors → 500 { err: \"Internal server error\" }.","fields":{"data":"array. Labels after the k / loc filters (whole list — no paging).","data[].id":"int. Label id. Pass as id to get_label / update_label / delete_label, as label_id to create_product / update_product / list_products.","data[].name":"string | null. Label name as typed (the dashboard shows it title-cased). null only if an update was sent without name.","data[].description":"string | null. Free-text description; \"\" or null = none (dashboard shows a \"No description\" chip).","data[].customer_id":"int. Tenant (merchant) id — always your own; not needed.","data[].location_id":"int | int[]. Branches (list_locations) the label is linked to: a bare int when exactly one, else an array ([] = on no branch). Always the full list, even when loc filters.","err":"string. Error only: \"Internal server error\"."},"example":{"data":[{"id":50,"name":"Coffee Seeds","description":"","customer_id":437,"location_id":[907,908,909,910,922,1131]},{"id":51,"name":"Coffee beans","description":"","customer_id":437,"location_id":[907,909,908,910,922,1131]},{"id":53,"name":"Chai seeds","description":"","customer_id":437,"location_id":[907,908,909,922,910,1131]},{"id":60,"name":"Garments","description":"","customer_id":437,"location_id":[922,907,908,909,910,1131]}]},"master_rules":["The dashboard Labels page (Products → Labels) calls this with loc and k only: loc starts \"\" (all labels), the Location filter sends one id or, on master tenants, a csv of the ticked branches (\"All\" → all); k comes from the \"Search label...\" box. It pages the returned array in the browser (5/10/25 per page) — the API has no paging.","Each row shows name (title-cased) and description (a \"No description\" chip when empty) with Edit / Delete actions; the Edit drawer is filled from this list row (it never calls get_label), so this row is enough to build update_label.","Product forms (create_product / update_product), the product list label filter (list_products label_id), menus and the product sales report load this list with NO loc, so every tenant label is offered whatever its branches: a label's branch list only filters this list. To pick a label_id for a product, call list_labels without loc (or k=<name>).","Products link by id: list_products label_id=<id> lists the products using a label; get_product [0].label_id, list_selling_products data.products[].label_id and sync_invoice / list_orders_history items[].label_id carry the same id.","Branch ids in location_id come from list_locations. A label with location_id [] is on no branch and is hidden whenever loc is sent (MCP always sends loc when the key is branch-scoped)."]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id or comma-separated branch ids (list_locations)."},"k":{"type":"string","description":"Case-insensitive substring match on the label name, done in code (not SQL): % and _ are literal characters."}},"required":[]},"module":"labels","safety":"read"},{"name":"get_label","description":"GET /api/label/:id. Why: One label with its branch list, to confirm a create_label / update_label or read the full record (name, description, location_id) that update_label must resend. Get the id from list_labels (or a product's label_id from list_products / get_product). The products using it: list_products label_id=<id>. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_label\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/label/:id","why":"One label with its branch list, to confirm a create_label / update_label or read the full record (name, description, location_id) that update_label must resend. Get the id from list_labels (or a product's label_id from list_products / get_product). The products using it: list_products label_id=<id>.","response":{"description":"200 → { label, message }. label = the raw label row with location_id replaced by its labels_locations branch list. 404 → { err: \"No label found\" } for an unknown id or another tenant's label. 500 → { err: \"Internal server error\" }.","fields":{"label":"object. The label.","label.id":"int. Label id.","label.name":"string | null. Label name.","label.description":"string | null. Description; \"\" or null = none.","label.customer_id":"int. Tenant id — always your own; not needed.","label.location_id":"int | int[] | string. Branch ids (list_locations): int when exactly one, array otherwise ([] = no branch). A legacy label with its own location_id column set returns that raw string instead.","message":"string. \"Label retrieved successfully\".","err":"string. Error only: \"No label found\" (404) or \"Internal server error\" (500)."},"example":{"label":{"id":50,"name":"Coffee Seeds","description":"","customer_id":437,"location_id":[907,908,909,910,922,1131]},"message":"Label retrieved successfully"},"master_rules":["frontend-v2 never calls this route (the edit drawer uses the list_labels row). Use it to confirm one label after create_label / update_label, or to read its branch list before update_label.","location_id here comes from labels_locations and has the same shape as list_labels (int when one branch, else array). A legacy label whose own location_id column is set returns that raw column value instead (a string such as \"907\").","To see which products use the label call list_products label_id=<id>; update_label then needs the full record (name, description, location_id) from this response."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Label id (list_labels data[].id, or a product's label_id)."}},"required":["id"]},"module":"labels","safety":"read"},{"name":"create_label","description":"POST /api/label. Why: Add a product label (dashboard Products → Labels → Add). Check list_labels k=<name> first — duplicates are not rejected on master tenants. Branch ids from list_locations. Then put it on products with create_product / update_product label_id (full product from get_product); when product_unique_formula is \"name,label_id\" the same product name may exist once per label. Confirm with get_label. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_label\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/label","why":"Add a product label (dashboard Products → Labels → Add). Check list_labels k=<name> first — duplicates are not rejected on master tenants. Branch ids from list_locations. Then put it on products with create_product / update_product label_id (full product from get_product); when product_unique_formula is \"name,label_id\" the same product name may exist once per label. Confirm with get_label.","response":{"description":"200 → a TWO-ELEMENT ARRAY: [0] = { label } (echo of what was inserted, not a DB read; location_id = the parsed branch array), [1] = { labels_locations } (the rows inserted). No message on success. Errors: 400 { err: \"Name can't be blank\" }; 500 { error: \"Failed to create label\" } | { error: \"Failed to link label to locations\" } (the label row already exists without branches — fix with update_label) | { error: \"Label creation failed\" }. (The 400 { err, message: \"Label with this name already exists\" } branch is non-master only.)","fields":{"[0].label":"object. Echo of the created label.","[0].label.name":"string. Name as sent.","[0].label.description":"string. Description as sent; key absent when omitted (stored NULL).","[0].label.customer_id":"int. Tenant id.","[0].label.location_id":"int[]. Branch ids parsed from the body (always an array here).","[0].label.id":"int. New label id → get_label / update_label / delete_label, and label_id for create_product / update_product.","[1].labels_locations":"array. One row per linked branch ([] when location_id was []).","[1].labels_locations[].id":"int. labels_locations row id (computed from the insert id). Not needed.","[1].labels_locations[].label_id":"int. The new label id.","[1].labels_locations[].location_id":"int. Branch id.","err":"string. Error only (400): \"Name can't be blank\".","error":"string. Error only (500): \"Failed to create label\", \"Failed to link label to locations\" or \"Label creation failed\"."},"example_request":{"name":"Coffee beans","description":"","location_id":[907,908]},"example":[{"label":{"name":"Coffee beans","description":"","customer_id":437,"location_id":[907,908],"id":61}},{"labels_locations":[{"id":5120,"label_id":61,"location_id":907},{"id":5121,"label_id":61,"location_id":908}]}],"master_rules":["The dashboard Add Label drawer sends { name, description, location_id }: name required, description \"\" when empty, location_id = the ticked Locations as an int array (master: at least one required; no default ticked). Send the same.","Check list_labels k=<name> first — duplicate names are accepted on master tenants.","The label is just a tag: after creating, set it on products with update_product label_id=<id> (send the full product from get_product) or pass label_id in create_product. When the tenant's product_unique_formula is \"name,label_id\", products are unique per (name, label_id), so the same product name can exist once per label, and the product form requires a label.","On success the dashboard shows \"Label created successfully\", closes the drawer and refetches list_labels; do the same (list_labels k=<name>) to confirm."]},"parameters":{"type":"object","properties":{"name":{"type":"string","description":"Label name (varchar 255), stored as sent (not trimmed)."},"description":{"type":"string","description":"Free-text description (TEXT)."},"location_id":{"type":"array","items":{"type":"number","description":"Branch (location) id from list_locations."},"description":"Branches the label is offered at — one labels_locations row per id (the label row's own location_id stays NULL on master tenants)."}},"required":["name","location_id"]},"module":"labels","safety":"write"},{"name":"update_label","description":"PUT /api/label/:id. Why: Rename a label, change its description or its branch list (dashboard Edit Label). NOT a patch — resend name, description and every branch from list_labels / get_label. Products keep label_id, so a rename shows on list_products, create_product / update_product label pickers and on sold lines (sync_invoice / list_orders_history items[].label_id). Branch ids from list_locations. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_label\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/label/:id","why":"Rename a label, change its description or its branch list (dashboard Edit Label). NOT a patch — resend name, description and every branch from list_labels / get_label. Products keep label_id, so a rename shows on list_products, create_product / update_product label pickers and on sold lines (sync_invoice / list_orders_history items[].label_id). Branch ids from list_locations.","response":{"description":"200 → { label, message }: label is an echo of what was written (not a DB read). labels_locations is deleted and re-inserted from location_id. 404 → { err: \"No label found\" } (unknown id / other tenant). 500 → { err: \"Internal server error\" } (not transactional: the row may already be updated when the branch re-insert fails — check get_label).","fields":{"label":"object. Echo of the update.","label.id":"string. The path id as a STRING (e.g. \"50\").","label.name":"string. Name as sent (key absent when omitted, stored NULL).","label.description":"string. Description as sent (key absent when omitted, stored NULL).","label.customer_id":"int. Tenant id.","label.location_id":"int[]. New branch list (always an array here).","message":"string. \"Label updated successfully\".","err":"string. Error only: \"No label found\" (404) or \"Internal server error\" (500)."},"example_request":{"id":50,"name":"Coffee Seeds","description":"Whole-bean products","location_id":[907,908,909,910,922,1131]},"example":{"label":{"id":"50","name":"Coffee Seeds","description":"Whole-bean products","customer_id":437,"location_id":[907,908,909,910,922,1131]},"message":"Label updated successfully"},"master_rules":["The dashboard Edit Label drawer is prefilled from the list_labels row and sends the full record { name, description, location_id (ticked Locations as an int array, at least one on master) } — do the same: get the row from list_labels / get_label, change what you need, resend everything.","location_id is a full replace of the label's branches; the branch list only affects which labels list_labels returns with loc — products keep label_id whatever the branches.","Renaming changes the name everywhere the label is shown (product forms, list_products label filter, product sales report); products, get_product label_id, list_selling_products and sold lines (sync_invoice / list_orders_history items[].label_id) keep the id.","On success the dashboard shows the message and refetches list_labels; confirm with get_label."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Label id (list_labels data[].id)."},"name":{"type":"string","description":"Label name (varchar 255), stored as sent."},"description":{"type":"string","description":"Free-text description (TEXT)."},"location_id":{"type":"array","items":{"type":"number","description":"Branch (location) id from list_locations."},"description":"Branches the label is offered at — one labels_locations row per id (the label row's own location_id stays NULL on master tenants)."}},"required":["id","name","location_id"]},"module":"labels","safety":"write"},{"name":"delete_label","description":"DELETE /api/label/:id. Why: Permanently delete a label and its branch links (dashboard Labels → Delete). Refused while an active product uses it: find them with list_products label_id=<id> and move them with update_product first. Archived products, menus and past sale lines (sync_invoice / list_orders_history items[].label_id) keep the dead id. Get the id from list_labels. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_label\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/label/:id","why":"Permanently delete a label and its branch links (dashboard Labels → Delete). Refused while an active product uses it: find them with list_products label_id=<id> and move them with update_product first. Archived products, menus and past sale lines (sync_invoice / list_orders_history items[].label_id) keep the dead id. Get the id from list_labels.","response":{"description":"200 → { message: \"Label deleted successfully\" } (label row and its labels_locations rows removed). 400 → { err, message: \"Cannot delete label: it is linked to one or more products.\" } when a product with is_archive = 0 and is_deleted = 0 has this label_id. 404 → { err: \"No label found to delete\" }. 500 → { err: \"Internal server error\" }.","fields":{"message":"string. \"Label deleted successfully\" on success; on the 400 the same text as err.","err":"string. Error only: \"Cannot delete label: it is linked to one or more products.\" (400), \"No label found to delete\" (404) or \"Internal server error\" (500)."},"example":{"message":"Label deleted successfully"},"master_rules":["The dashboard Delete action asks \"Are you sure? You won't be able to revert this!\" and then calls this; on 400 it shows the err text. Ask the user to confirm the same way — there is no undo and no archive for labels.","Before deleting, list_products label_id=<id> (and is_archive=1 for archived ones): the server refuses only while an ACTIVE, non-deleted product uses the label. Move those products to another label (update_product label_id) or clear it first.","Archived products, menus and past sale lines (sync_invoice / list_orders_history items[].label_id) keep the deleted id; restoring an archived product then points at a missing label. The delete is tenant-wide — every branch loses the label.","Confirm with list_labels (the id is gone) or get_label (404)."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Label id (list_labels data[].id)."}},"required":["id"]},"module":"labels","safety":"destructive"},{"name":"list_deals","description":"GET /api/deals. Why: Dashboard deal list: the tenant's live combos (is_combo 1, not archived/disabled/add-on) with price, branch list, channel prices and BOM cost — the catalog-management view, not the POS menu (list_selling_products shows what a branch sells, with is_combo / categories[] slots / combo_price). Get ids here for get_deal → update_deal / delete_deal. loc takes branch ids from list_locations. Slot ids + max_allowed are only in get_deal. Paginated with _page/_limit (default 10 per page). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_deals\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/deals","why":"Dashboard deal list: the tenant's live combos (is_combo 1, not archived/disabled/add-on) with price, branch list, channel prices and BOM cost — the catalog-management view, not the POS menu (list_selling_products shows what a branch sells, with is_combo / categories[] slots / combo_price). Get ids here for get_deal → update_deal / delete_deal. loc takes branch ids from list_locations. Slot ids + max_allowed are only in get_deal. Paginated with _page/_limit (default 10 per page).","response":{"description":"200 → { items, total, page, perPage }. items = raw products columns (SELECT p.*) of each deal plus override_* from a products_locations join, web_image, cost, price_levels[] and (with is_combo) categories. Server filters: tenant = session, is_combo = 1, is_archive = 0, is_disabled = 0, is_addon = 0, plus loc / k. Sorted id DESC. With no loc, a deal that also has products_locations rows repeats once per row (create_deal never writes them). Errors → 500 { error: \"Failed to fetch deals\", detail }. Live on dev1 tenant 437 (branch 907): no deals, so the verified example is the empty envelope; item fields are from the handler and the products table.","fields":{"items":"array. Deals on this page (_page/_limit applied after the query). [] when the tenant has none.","items[].id":"int. Deal id (= products.id). Pass as `id` to get_deal / update_deal / delete_deal. Same id sync_invoice uses as the combo parent line product_id (list_selling_products shows it too when the deal is on the branch menu).","items[].category_id":"int | null. Unused on deals (the deal form never sets it). Slot categories are in categories.","items[].name":"string. Deal name (dashboard shows it title-cased).","items[].description":"string | null. Free-text description (dashboard list column; \"—\" when empty).","items[].price":"number | null. Price without VAT as the dashboard form computed it (actual_price − vat).","items[].stock":"number | null. Legacy column, not used for deals. Not needed.","items[].type":"string. Always \"combo\" (forced by create/update).","items[].image":"string. Absolute image URL (<BACKEND_URL>/api/uploads/<file>) or \"\" when none.","items[].customer_id":"int. Tenant id. Always your own tenant; not needed.","items[].location_id":"int | int[]. Branches the deal is assigned to (deals_locations): a bare int when exactly one, an array when several, [] when none. Master deals store products.location_id = null and keep the list in deals_locations.","items[].is_addon":"0 | 1. Always 0 on listed deals (list_deals filters is_addon = 0).","items[].is_disabled":"0. Always 0 on listed deals. Not needed.","items[].discount_pct":"number | null. Deal discount percent (0–100; 0 = none). Dashboard field \"Discount Percentage\".","items[].is_archive":"0 | 1. 0 on list_deals (archived deals are filtered out); on get_deal equals the is_archive query you sent. Archive/restore with archive_product.","items[].barcode_type":"string | null. Barcode symbology picked in the form (\"EAN-13\" | \"QR Code\"; \"\"/null = none).","items[].barcode_value":"string | null. Barcode / \"Deal Code Number\" scanned at the POS (\"\"/null = none).","items[].is_open_price":"0 | 1 | null. Not used for deals (create stores null). Not needed.","items[].is_combo":"1. Always 1 — the deal tools only create and list fixed-price combos (list_selling_products is_combo 1). is_combo 2 (per-child price) combos are not made or listed by these tools.","items[].sync_deliverect":"0 | 1. Deliverect sync flag. Not needed.","items[].sync_otter":"0 | 1. Otter sync flag. Not needed.","items[].new_cost_date":"string (ISO timestamp) | null. Legacy. Not needed.","items[].product_cost":"number | null. Legacy cost column (deal cost is `cost`). Not needed.","items[].is_weightage_item":"0 | 1 | null. Not used for deals. Not needed.","items[].unit_id":"int | null. Not used for deals. Not needed.","items[].actual_price":"number | null. Deal price, VAT included — what the POS charges for the combo parent line (sync_invoice BUILD 9) and the dashboard list \"Price\" column.","items[].old_price":"number | null. Legacy. Not needed.","items[].vat":"number | null. Tax AMOUNT stored by the form (not a percent) — tax is out of scope; resend unchanged.","items[].localized":"string | null. Legacy. Not needed.","items[].base_price":"number | null. Legacy. Not needed.","items[].menu_sort_order":"int. Order of the deal in the web-pos Combos list (ascending, 0 sorts last). Not settable through the deal tools.","items[].plu":"int | null. PLU short code (not set by the deal tools).","items[].calories":"int | null. Not set by the deal tools.","items[].is_stock_product":"0 | 1 | null. Not used for deals. Not needed.","items[].is_auto_apply":"0 | 1 | null. Legacy. Not needed.","items[].combo_price":"number. Unused on the deal row itself; combo_price matters on the CHILD products (list_selling_products / update_product).","items[].localize_name":"string | null. Second-language name (not set by the deal tools).","items[].localize_description":"string | null. Second-language description (not set by the deal tools).","items[].label_id":"int | null. Not set by the deal tools.","items[].recipes":"string | null. Legacy. Not needed.","items[].sku_no":"string | null. \"deal-<customer_id>-<id>\", filled by a background job shortly after create_deal (null for a moment). Shown read-only on the edit form.","items[].tax_id":"int | null. Tax group — out of scope; not written by the deal tools.","items[].preparation_time":"int | null. Not set by the deal tools.","items[].product_recipe_total":"number | null. Legacy cost part. Not needed.","items[].product_material_total":"number | null. Legacy cost part. Not needed.","items[].is_overridden":"0 | 1. Product-branch price flag; not used for deals. Not needed.","items[].is_deleted":"0 | 1. Soft-delete flag of products; deals are hard-deleted by delete_deal. Not needed.","items[].category_sort_order":"int | null. Legacy. Not needed.","items[].addons":"string | null. Raw JSON string (legacy add-on list, e.g. \"[]\"). Not parsed here; not needed.","items[].bundle_items":"string | null. Raw JSON string (legacy, only written by update_deal). Not needed.","items[].modifiers":"array. Legacy modifiers column split on \",\" and Number()ed; dashboard-made deals give [null] (column \"[]\"). Not needed — combos carry no option groups.","items[].override_location_id":"int | null. Branch of a products_locations row joined to the deal; null for deals made by create_deal (they use deals_locations). Not needed.","items[].override_price":"number | null. That branch row's price; null for normal deals. Not needed.","items[].override_actual_price":"number | null. That branch row's VAT-incl. price; null for normal deals. Not needed.","items[].web_image":"string. Relative \"api/uploads/<file>\" path, \"\" when no image. Dashboard list thumbnail.","items[].cost":"number | \"-\". Sum of the deal's BOM material costs; the string \"-\" when 0 / none. Dashboard \"Cost\" column. Materials are out of scope.","items[].price_levels":"array. Sales-channel prices of the deal ([] = none).","items[].price_levels[].channel_id":"int. Channel id (list_channels).","items[].price_levels[].channel_name":"string. Channel name.","items[].price_levels[].channel_price":"number. Channel price before VAT.","items[].price_levels[].channel_vat":"number. Tax part — tax out of scope; ignore.","items[].price_levels[].channel_actual_price":"number. Channel price VAT incl. (0 when never set).","items[].categories":"string[]. ONLY when is_combo is sent: NAMES of the slot categories. Absent otherwise. For slot ids and max_allowed use get_deal.","total":"int. Number of deals matching the filters (all pages).","page":"int. The _page used (default 1).","perPage":"int. The _limit used (default 10)."},"example_request":{"loc":"907","is_combo":"1","_page":1,"_limit":10},"example":{"items":[],"total":0,"page":1,"perPage":10},"master_rules":["The dashboard Deals page (/dashboard/products/deals) calls this with loc (location filter: one id, csv when several, \"All\"), k (search box), is_combo 1, _page and _limit 10 (plus cat \"All\", adm, type, brand_id which the server ignores). Reproduce a page with the same params; for everything send a large _limit and check total.","Each row shows name (title-cased), description (\"—\" empty), Price = actual_price, VAT = vat, Cost = cost (\"-\" when no BOM), image thumbnail from web_image, and Edit / Delete buttons (permissions products.deals.edit / .delete). Clicking a row opens the edit form, which loads get_deal.","Before quoting a deal to a customer, confirm it is on the branch menu with list_selling_products (is_combo 1 rows); list_deals shows catalog data, not what a branch sells.","Archived deals are not listed (archive_product on a deal id hides it here and from the POS; get_deal with is_archive=1 still reads it)."]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id or csv of ids (list_locations): deals assigned to any of them (deals_locations, or legacy products.location_id)."},"k":{"type":"string","description":"Substring match on the deal name (LIKE %k%; % and _ are wildcards)."},"is_combo":{"type":"string","enum":["1"],"description":"Any value → adds categories = the slot category NAMES (string[]) to each row."},"_page":{"type":"number","description":"1-based page."},"_limit":{"type":"number","description":"Page size."}},"required":[]},"module":"deals","safety":"read"},{"name":"get_deal","description":"GET /api/deals/:id. Why: Full deal record for an edit: branch list, choice slots (categories[] with category_id + max_allowed — the same slots list_selling_products gives the POS), channel prices and image. Always call it before update_deal and resend its values (update_deal overwrites every column). Slot category ids resolve with list_categories / list_selling_categories; channel ids with list_channels; branch ids with list_locations. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_deal\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/deals/:id","why":"Full deal record for an edit: branch list, choice slots (categories[] with category_id + max_allowed — the same slots list_selling_products gives the POS), channel prices and image. Always call it before update_deal and resend its values (update_deal overwrites every column). Slot category ids resolve with list_categories / list_selling_categories; channel ids with list_channels; branch ids with list_locations.","response":{"description":"200 → [deal] (array of one) with raw products columns + location_name, web_image, modifier/modifiers, parsed addons/bundle_items, categories[], channel_price[], materials[]. 200 → \"No records found\" (a JSON string, not an object) when the id is not a combo (is_combo 1) with that is_archive that is assigned to the SESSION USER'S branch (master: deals_locations) — a deal on other branches only is not readable even though list_deals shows it. Not filtered by tenant or is_disabled. 500 → { err: \"Database error\" }. Live on dev1: tenant 437 has no deals; GET /api/deals/1 returned \"No records found\" (the example). Fields are from the handler and the products table.","fields":{"[]":"array of exactly one deal object — or the plain JSON string \"No records found\" (HTTP 200) when the id is not a live combo on the session branch.","[].id":"int. Deal id (= products.id). Pass as `id` to get_deal / update_deal / delete_deal. Same id sync_invoice uses as the combo parent line product_id (list_selling_products shows it too when the deal is on the branch menu).","[].category_id":"int | null. Unused on deals (the deal form never sets it). Slot categories are in categories.","[].name":"string. Deal name (dashboard shows it title-cased).","[].description":"string | null. Free-text description (dashboard list column; \"—\" when empty).","[].price":"number | null. Price without VAT as the dashboard form computed it (actual_price − vat).","[].stock":"number | null. Legacy column, not used for deals. Not needed.","[].type":"string. Always \"combo\" (forced by create/update).","[].image":"string. Absolute image URL (<BACKEND_URL>/api/uploads/<file>) or \"\" when none.","[].customer_id":"int. Tenant id. Always your own tenant; not needed.","[].location_id":"int | int[]. Branches from deals_locations: a bare int when exactly one, else an array ([] = none). Resend as a csv STRING on update_deal.","[].is_addon":"0 | 1. Always 0 on listed deals (list_deals filters is_addon = 0).","[].is_disabled":"0. Always 0 on listed deals. Not needed.","[].discount_pct":"number | null. Deal discount percent (0–100; 0 = none). Dashboard field \"Discount Percentage\".","[].is_archive":"0 | 1. 0 on list_deals (archived deals are filtered out); on get_deal equals the is_archive query you sent. Archive/restore with archive_product.","[].barcode_type":"string | null. Barcode symbology picked in the form (\"EAN-13\" | \"QR Code\"; \"\"/null = none).","[].barcode_value":"string | null. Barcode / \"Deal Code Number\" scanned at the POS (\"\"/null = none).","[].is_open_price":"0 | 1 | null. Not used for deals (create stores null). Not needed.","[].is_combo":"1. Always 1 — the deal tools only create and list fixed-price combos (list_selling_products is_combo 1). is_combo 2 (per-child price) combos are not made or listed by these tools.","[].sync_deliverect":"0 | 1. Deliverect sync flag. Not needed.","[].sync_otter":"0 | 1. Otter sync flag. Not needed.","[].new_cost_date":"string (ISO timestamp) | null. Legacy. Not needed.","[].product_cost":"number | null. Legacy cost column (deal cost is `cost`). Not needed.","[].is_weightage_item":"0 | 1 | null. Not used for deals. Not needed.","[].unit_id":"int | null. Not used for deals. Not needed.","[].actual_price":"number | null. Deal price, VAT included — what the POS charges for the combo parent line (sync_invoice BUILD 9) and the dashboard list \"Price\" column.","[].old_price":"number | null. Legacy. Not needed.","[].vat":"number | null. Tax AMOUNT stored by the form (not a percent) — tax is out of scope; resend unchanged.","[].localized":"string | null. Legacy. Not needed.","[].base_price":"number | null. Legacy. Not needed.","[].menu_sort_order":"int. Order of the deal in the web-pos Combos list (ascending, 0 sorts last). Not settable through the deal tools.","[].plu":"int | null. PLU short code (not set by the deal tools).","[].calories":"int | null. Not set by the deal tools.","[].is_stock_product":"0 | 1 | null. Not used for deals. Not needed.","[].is_auto_apply":"0 | 1 | null. Legacy. Not needed.","[].combo_price":"number. Unused on the deal row itself; combo_price matters on the CHILD products (list_selling_products / update_product).","[].localize_name":"string | null. Second-language name (not set by the deal tools).","[].localize_description":"string | null. Second-language description (not set by the deal tools).","[].label_id":"int | null. Not set by the deal tools.","[].recipes":"string | null. Legacy. Not needed.","[].sku_no":"string | null. \"deal-<customer_id>-<id>\", filled by a background job shortly after create_deal (null for a moment). Shown read-only on the edit form.","[].tax_id":"int | null. Tax group — out of scope; not written by the deal tools.","[].preparation_time":"int | null. Not set by the deal tools.","[].product_recipe_total":"number | null. Legacy cost part. Not needed.","[].product_material_total":"number | null. Legacy cost part. Not needed.","[].is_overridden":"0 | 1. Product-branch price flag; not used for deals. Not needed.","[].is_deleted":"0 | 1. Soft-delete flag of products; deals are hard-deleted by delete_deal. Not needed.","[].category_sort_order":"int | null. Legacy. Not needed.","[].location_name":"string | null. Name of products.location_id's branch — null for master deals (location_id is stored null). Not needed.","[].web_image":"string. Relative \"api/uploads/<file>\" path, \"\" when no image.","[].modifier":"string | null. Raw legacy modifiers column (e.g. \"[]\"). Not needed.","[].modifiers":"array. Same column parseInt()ed per comma part; dashboard-made deals give [null]. Not needed.","[].addons":"array. Parsed legacy add-on list (full product objects when the dashboard Add On tab was used; [] when none). Not used by the POS. Not needed.","[].bundle_items":"array. Parsed legacy bundle list (full product objects from the dashboard Bundle tab; [] when none). Not used by the POS. Not needed.","[].categories":"array. The deal's choice slots (deal_categories), in insert order ([] = none). Same shape the POS gets in list_selling_products categories[].","[].categories[].category_id":"int. Slot category id (list_categories / list_selling_categories). The POS offers products whose type equals that category's name.","[].categories[].max_allowed":"int. How many products the customer picks from this category (web-pos requires every slot to be filled to exactly this many).","[].channel_price":"array. Sales-channel prices ([] = none).","[].channel_price[].channel_id":"int. Channel id (list_channels).","[].channel_price[].channel_name":"string. Channel name.","[].channel_price[].channel_price":"number. Channel price before VAT. (channel_vat / channel_actual_price are NOT returned here — list_deals price_levels has them.)","[].materials":"array. BOM rows of the deal ([] = none). Materials are out of scope.","[].materials[].id":"int. Material id. Out of scope.","[].materials[].name":"string. Material name. Out of scope.","[].materials[].description":"string | null. Out of scope.","[].materials[].price":"number | null. Material price. Out of scope.","[].materials[].unit":"string | null. Out of scope.","[].materials[].ingredientunit":"string | null. Out of scope.","[].materials[].is_prepared":"0 | 1 | null. Out of scope.","[].materials[].conversionrate":"number | null. Out of scope.","[].materials[].costingmethod":"string | null. Out of scope.","[].materials[].avg_price":"number | null. Out of scope.","[].materials[].material_id":"int. Same as id. Out of scope.","[].materials[].qty":"number. Quantity per deal. Out of scope.","[].materials[].wastage":"number | null. Out of scope."},"example_request":{"id":1},"example":"No records found","master_rules":["The dashboard edit page (/dashboard/products/deals/edit?mode=edit&id=<id>) loads this, takes element [0] and pre-fills the form: name, description, barcode_type, barcode_value, sku_no (read-only), actual_price, vat, price, discount_pct, location_id (as a csv string), categories (ticked with their Qty), bundle_items / addons / materials tabs, and image.","Always check for the string \"No records found\" before reading [0]. If list_deals shows the deal but get_deal does not, the deal is not on the session user's branch.","To update: take this record, change what you need and send name, description, actual_price, price, vat, discount_pct, barcode_type, barcode_value, image, location_id (csv string), categories and channel_price to update_deal — anything omitted is cleared.","channel_price here lacks channel_vat / channel_actual_price; take them from list_deals price_levels before resending."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Deal id (list_deals items[].id)."},"is_archive":{"type":"number","description":"0 → only a live deal, 1 → only an archived deal (archive_product)."}},"required":["id"]},"module":"deals","safety":"read"},{"name":"create_deal","description":"POST /api/deals. Why: Add a fixed-price combo (is_combo 1, type \"combo\"): a name, a VAT-incl. actual_price, the branches (list_locations) and the choice slots — categories[] { category_id from list_categories, max_allowed }. At the POS the customer picks max_allowed products from each slot category (products whose type equals the category name, list_selling_products); the chosen children are sold as combo_items under one combo parent line in sync_invoice (BUILD 9); a child's combo_price (update_product) adds to its line. Channel prices: price_level with list_channels ids. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_deal\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/deals","why":"Add a fixed-price combo (is_combo 1, type \"combo\"): a name, a VAT-incl. actual_price, the branches (list_locations) and the choice slots — categories[] { category_id from list_categories, max_allowed }. At the POS the customer picks max_allowed products from each slot category (products whose type equals the category name, list_selling_products); the chosen children are sold as combo_items under one combo parent line in sync_invoice (BUILD 9); a child's combo_price (update_product) adds to its line. Channel prices: price_level with list_channels ids.","response":{"description":"200 → the request body as the server mutated it (type \"combo\", customer_id, defaults for discount_pct, is_addon, addons, modifier, barcode_type, barcode_value, image, is_open_price, actual_price, vat) plus id and plu — NOT a DB read (sku_no is filled later by a background job; call get_deal / list_deals). 400 → { err: \"name can't be blank\" }. 500 → { err: \"Failed to create deal\", detail }. Not transactional: the deal row and branch rows are written first, so a bad slot / channel row gives 500 with the deal already created — check list_deals before retrying.","fields":{"id":"int. New deal id.","plu":"int | null. PLU of the new row (null).","name":"string. As sent.","type":"string. \"combo\".","customer_id":"int. Your tenant id.","discount_pct":"number. As sent, else 0.","is_addon":"boolean | number. false unless sent.","addons":"array. [] unless sent.","modifier":"null | array. null unless sent.","barcode_type":"string | null. As sent, else null.","barcode_value":"string | null. As sent, else null.","image":"string. As sent, else \"\".","is_open_price":"null | number. null unless sent.","actual_price":"number. As sent, else 0.","vat":"number. As sent, else 0.","description":"string. Echoed as sent (absent when not sent).","price":"number. Echoed as sent (absent when not sent).","location_id":"string | number. Echoed as sent (MCP may have filled it from the key).","categories":"array. Echoed as sent.","categories[].category_id":"int. Echoed.","categories[].max_allowed":"int. Echoed.","price_level":"array. Echoed as sent (absent when not sent).","err":"string. Error responses only (400 / 500).","detail":"string. 500 only: the database error message."},"example_request":{"name":"Lunch Combo","description":"Burger + side + drink","actual_price":35,"price":35,"discount_pct":0,"location_id":"907","categories":[{"category_id":15231,"max_allowed":1},{"category_id":15240,"max_allowed":2}]},"example":{"name":"Lunch Combo","description":"Burger + side + drink","actual_price":35,"price":35,"discount_pct":0,"location_id":"907","categories":[{"category_id":15231,"max_allowed":1},{"category_id":15240,"max_allowed":2}],"type":"combo","customer_id":437,"is_addon":false,"addons":[],"modifier":null,"barcode_type":null,"barcode_value":null,"image":"","is_open_price":null,"vat":0,"id":260001,"plu":null},"master_rules":["The dashboard Add Deal form (/dashboard/products/deals/edit?mode=create) validates: name required (trimmed, non-empty), actual_price required number, at least one branch ticked. It sends name, description, barcode_type (\"EAN-13\" / \"QR Code\" / \"\"), barcode_value, actual_price, vat, price, discount_pct, location_id (csv of ticked branches), categories (ticked categories with Qty 1–30), bundle_items / addons / materials from the Ingredients tabs, image, and constants type \"\", stock null, is_addon false, modifier [], channel_price [], price_level []. Do the same with name, actual_price, price, location_id and categories.","Price maths in the form: typing Actual Price sets vat = 5% of actual_price (rounded to 2 dp) and price = actual_price − vat — a flat amount, not the config VAT rate (list_products/create_product use get_config_value). Tax is out of scope: omit vat (0) and send price = actual_price, or copy the dashboard maths if the merchant expects it.","Slots: tick categories from list_categories (the dashboard dropdown calls it with is_active=true, which the server reads as 0 → it actually offers only INACTIVE categories; pick active ones, is_active=1). Every slot needs a numeric max_allowed. A category's products reach the slot only if their type equals the category name (list_selling_products).","Selling: create_deal writes branches to deals_locations only. On a master tenant the POS menu (list_selling_products) inner-joins products_locations, so a new deal may NOT appear on any branch menu — check list_selling_products (is_combo 1) after creating and report it if missing; do not work around it with update_product.","Image: the dashboard uploads to POST /api/uploads but sends the result as web_image, which the server ignores, so dashboard uploads are not saved on create. Send image = the relative file name.","Duplicates are not checked: search list_deals by name first."]},"parameters":{"type":"object","properties":{"name":{"type":"string","description":"Deal name (dashboard: required, trimmed, non-empty)."},"description":{"type":"string","description":"Free-text description."},"actual_price":{"type":"number","description":"Deal price VAT included — what the POS charges for the combo (dashboard: required number)."},"price":{"type":"number","description":"Price without VAT."},"vat":{"type":"number","description":"Tax AMOUNT (not a percent)."},"discount_pct":{"type":"number","description":"Deal discount percent."},"barcode_type":{"type":"string","enum":["EAN-13","QR Code",""],"description":"Barcode symbology (dashboard \"Code Type\" select)."},"barcode_value":{"type":"string","description":"Barcode / \"Deal Code Number\"."},"image":{"type":"string","description":"Relative upload file name (e.g."},"location_id":{"type":"string","description":"Branch ids (list_locations) as a csv string, e.g."},"categories":{"type":"array","items":{"type":"object","properties":{"category_id":{"type":"number","description":"Slot category id (list_categories)."},"max_allowed":{"type":"number","description":"How many products the customer picks from this category (1–30 in the dashboard)."}},"required":["category_id","max_allowed"],"description":""},"description":"Choice slots: one row per category the customer picks from, with max_allowed."},"price_level":{"type":"array","items":{"type":"object","properties":{"channel_id":{"type":"number","description":"Channel id (list_channels)."},"channel_name":{"type":"string","description":"Channel name as in list_channels (stored as given)."},"channel_price":{"type":"number","description":"Channel price before VAT."},"channel_vat":{"type":"number","description":"Tax part — tax out of scope; omit (0) or resend."},"channel_actual_price":{"type":"number","description":"Channel price VAT incl."}},"required":["channel_id","channel_price"],"description":""},"description":"Channel prices (product_channel_price), CREATE key — update uses channel_price."}},"required":["name"]},"module":"deals","safety":"write"},{"name":"update_deal","description":"PUT /api/deals/:id. Why: Edit one deal (price, name, branches, choice slots, channel prices, image). It is a FULL overwrite, not a patch: start from get_deal and resend every value; categories (slots → list_categories ids), location_id (branches → list_locations) and channel_price (list_channels) are fully replaced. Slot changes reach the POS through list_selling_products categories[]; to hide a deal use archive_product, to remove it delete_deal. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_deal\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/deals/:id","why":"Edit one deal (price, name, branches, choice slots, channel prices, image). It is a FULL overwrite, not a patch: start from get_deal and resend every value; categories (slots → list_categories ids), location_id (branches → list_locations) and channel_price (list_channels) are fully replaced. Slot changes reach the POS through list_selling_products categories[]; to hide a deal use archive_product, to remove it delete_deal.","response":{"description":"200 → { ...the request body (type \"combo\", customer_id added, image cut to the relative path, discount_pct normalised), id: <path id as int> } — NOT a DB read; call get_deal to see stored values. 500 → { message: \"Internal Server Error\", error } (DB error; earlier steps stay written — not transactional). A missing / non-string image crashes the API process before any response. The id is not checked: an unknown id updates nothing but still rewrites slot, channel and branch rows for that id and returns 200.","fields":{"id":"int. The path id.","name":"string. As sent.","type":"string. \"combo\".","customer_id":"int. Your tenant id.","image":"string. Relative file name after \"uploads/\" (or as sent).","discount_pct":"number. As sent, or 0 when missing / out of 0–100.","description":"string. Echoed as sent.","actual_price":"number. Echoed as sent.","price":"number. Echoed as sent.","vat":"number. Echoed as sent (absent when not sent).","barcode_type":"string. Echoed as sent.","barcode_value":"string. Echoed as sent.","location_id":"string. Echoed as sent.","selected_location_ids":"int[]. Echoed when sent.","categories":"array. Echoed as sent.","categories[].category_id":"int. Echoed.","categories[].max_allowed":"int. Echoed.","channel_price":"array. Echoed as sent.","channel_price[].channel_id":"int. Echoed.","channel_price[].channel_name":"string. Echoed.","channel_price[].channel_price":"number. Echoed.","channel_price[].channel_vat":"number. Echoed.","channel_price[].channel_actual_price":"number. Echoed.","message":"string. 500 only: \"Internal Server Error\".","error":"string. 500 only: the database error message."},"example_request":{"id":260001,"name":"Lunch Combo","description":"Burger + side + drink","actual_price":39,"price":39,"vat":0,"discount_pct":0,"barcode_type":"","barcode_value":"","image":"https://dev1.syspos.ae/api/uploads/1780385109749-combo.png","location_id":"907,908","categories":[{"category_id":15231,"max_allowed":1},{"category_id":15240,"max_allowed":2}],"channel_price":[{"channel_id":12,"channel_name":"Talabat","channel_price":42,"channel_vat":0,"channel_actual_price":42}]},"example":{"id":260001,"name":"Lunch Combo","description":"Burger + side + drink","actual_price":39,"price":39,"vat":0,"discount_pct":0,"barcode_type":"","barcode_value":"","image":"1780385109749-combo.png","location_id":"907,908","categories":[{"category_id":15231,"max_allowed":1},{"category_id":15240,"max_allowed":2}],"channel_price":[{"channel_id":12,"channel_name":"Talabat","channel_price":42,"channel_vat":0,"channel_actual_price":42}],"type":"combo","customer_id":437},"master_rules":["The dashboard edit form sends the whole get_deal record back with the edited fields (plus id, web_image, sku_no, location_name, materials, addons, bundle_items… which the server ignores or overwrites). Validation and price maths are the same as create_deal; location_id is always a csv string.","Resend everything: name, description, actual_price, price, vat, discount_pct, barcode_type, barcode_value, image, location_id and categories from get_deal, and channel_price from list_deals price_levels (get_deal omits channel_vat / channel_actual_price). Omitted columns become NULL; omitted categories / channel_price delete every slot / channel price.","location_id MUST be a csv string of every branch the deal should keep (e.g. String(get_deal.location_id) for one branch, ids.join(\",\") for several). A number or array removes the deal from all branches.","image MUST be a string (get_deal image, a relative upload name, or \"\"); omitting it crashes the API server.","update_deal never changes is_archive: an archived deal stays archived (restore with archive_product is_archive=\"0\").","Do not edit deals with update_product: it treats the row as a normal product (category, branch prices) and is not what the dashboard does."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Deal id (list_deals items[].id)."},"name":{"type":"string","description":"Deal name (dashboard: required, trimmed, non-empty)."},"description":{"type":"string","description":"Free-text description."},"actual_price":{"type":"number","description":"Deal price VAT included — what the POS charges for the combo (dashboard: required number)."},"price":{"type":"number","description":"Price without VAT."},"vat":{"type":"number","description":"Tax AMOUNT (not a percent)."},"discount_pct":{"type":"number","description":"Deal discount percent."},"barcode_type":{"type":"string","enum":["EAN-13","QR Code",""],"description":"Barcode symbology."},"barcode_value":{"type":"string","description":"Barcode / \"Deal Code Number\"."},"image":{"type":"string","description":"REQUIRED STRING — the handler calls image.split() before its try block, so a missing/non-string image crashes the API process."},"location_id":{"type":"string","description":"REQUIRED csv STRING of ALL branch ids the deal should stay on, e.g."},"selected_location_ids":{"type":"array","items":{"type":"number","description":""},"description":"Alternative branch list as an array of ids; when present it wins over location_id."},"categories":{"type":"array","items":{"type":"object","properties":{"category_id":{"type":"number","description":"Slot category id (list_categories)."},"max_allowed":{"type":"number","description":"How many products the customer picks from this category (1–30 in the dashboard)."}},"required":["category_id","max_allowed"],"description":""},"description":"Choice slots — FULL replace: every deal_categories row is deleted and these are inserted."},"channel_price":{"type":"array","items":{"type":"object","properties":{"channel_id":{"type":"number","description":"Channel id (list_channels)."},"channel_name":{"type":"string","description":"Channel name as in list_channels (stored as given)."},"channel_price":{"type":"number","description":"Channel price before VAT."},"channel_vat":{"type":"number","description":"Tax part — tax out of scope; omit (0) or resend."},"channel_actual_price":{"type":"number","description":"Channel price VAT incl."}},"required":["channel_id","channel_price"],"description":""},"description":"Channel prices, UPDATE key (create uses price_level) — FULL replace: omitted/[] deletes every channel price."}},"required":["id","name","actual_price","price","image","location_id"]},"module":"deals","safety":"write"},{"name":"delete_deal","description":"DELETE /api/deals/:id. Why: Permanently remove a deal: hard-deletes the products row, its slots (deal_categories) and channel prices. Id from list_deals. Past orders keep their combo lines (sync_invoice history is untouched). To only hide a deal from the POS use archive_product on the deal id instead. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_deal\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/deals/:id","why":"Permanently remove a deal: hard-deletes the products row, its slots (deal_categories) and channel prices. Id from list_deals. Past orders keep their combo lines (sync_invoice history is untouched). To only hide a deal from the POS use archive_product on the deal id instead.","response":{"description":"200 → { status: true, message: \"Deal deleted successfully\" } (sent with res.end, no JSON content-type) — ALWAYS, even for an unknown id. No tenant, is_combo or branch check: the id is deleted from products whatever it is (a normal product id deletes that product for good). deals_locations rows are left behind (harmless). A database error has no handler and crashes the API process.","fields":{"status":"boolean. Always true.","message":"string. Always \"Deal deleted successfully\"."},"example_request":{"id":260001},"example":{"status":true,"message":"Deal deleted successfully"},"master_rules":["The dashboard deletes from the list row (trash icon, permission products.deals.delete) after a confirm dialog, then shows message as a success toast (status true) and refreshes the list. Always confirm with the user, naming the deal.","Only pass an integer id you just read from list_deals (is_combo 1). Never pass a product id from list_products — it would be hard-deleted.","Verify with list_deals afterwards; the response proves nothing."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Deal id (list_deals items[].id)."}},"required":["id"]},"module":"deals","safety":"destructive"},{"name":"list_channels","description":"GET /api/channels. Why: Dashboard list of the tenant's sales channels with their branch assignments (location_id), description and sku_no. Use it to find a channel id before get/update/delete, or to see which branches a channel is sold on. For what the POS offers at one branch use list_selling_channels. Linkage: a channel id is referenced by list_selling_products data.products[].price_levels[].channel_id (per-channel price, product_channel_price), by orders (channel_id + channel_name, stored verbatim by sync_invoice — web-pos sends 0/\"\"), and by discount codes channel_ids (list_discounts / get_user_extras data.discount_code[]). Branch availability lives in channels_locations (location_id in these tools); master channels have channels.location_id NULL. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_channels\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/channels","why":"Dashboard list of the tenant's sales channels with their branch assignments (location_id), description and sku_no. Use it to find a channel id before get/update/delete, or to see which branches a channel is sold on. For what the POS offers at one branch use list_selling_channels. Linkage: a channel id is referenced by list_selling_products data.products[].price_levels[].channel_id (per-channel price, product_channel_price), by orders (channel_id + channel_name, stored verbatim by sync_invoice — web-pos sends 0/\"\"), and by discount codes channel_ids (list_discounts / get_user_extras data.discount_code[]). Branch availability lives in channels_locations (location_id in these tools); master channels have channels.location_id NULL.","response":{"description":"A BARE JSON ARRAY (no envelope), one row per channel, ordered by name. location_id lists only the branches within the user's allow_locations (int when one, int[] when several). Errors: 403 { error: \"Access denied to one or more requested locations\" }; 500 { error: \"Failed to get channels\" }.","fields":{"[]":"array. Channels. [] = none.","[].id":"int. Channel id — the channel_id used on products' price_levels, orders and discount channel_ids.","[].name":"string. Channel name (also copied into product_channel_price.channel_name and order channel_name).","[].description":"string | null. Free text; \"\" when blank.","[].customer_id":"int. Tenant id.","[].sku_no":"string | null. Global SKU \"chn-<customer_id>-<id>\", set asynchronously after create (null for a moment).","[].location_id":"int | int[]. Branch(es) the channel is sold on (channels_locations + legacy channels.location_id), limited to the user's allowed branches. int when exactly one."},"caveats":"There is no search param (k is not read). A channel mapped to no branch (e.g. after update_channel without locations) never appears here.","example":[{"id":463,"name":"Coffee Bar","description":"","customer_id":437,"sku_no":"chn-437-463","location_id":[1131,910,922,907,908,909]},{"id":509,"name":"Express","description":"","customer_id":437,"sku_no":"chn-437-509","location_id":907}]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Optional."}},"required":[]},"module":"channels","safety":"read"},{"name":"get_channel","description":"GET /api/channels/:id. Why: Load one channel (name, description, branches) — call it before update_channel, which fully replaces name, description and branch list. Linkage: a channel id is referenced by list_selling_products data.products[].price_levels[].channel_id (per-channel price, product_channel_price), by orders (channel_id + channel_name, stored verbatim by sync_invoice — web-pos sends 0/\"\"), and by discount codes channel_ids (list_discounts / get_user_extras data.discount_code[]). Branch availability lives in channels_locations (location_id in these tools); master channels have channels.location_id NULL. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_channel\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/channels/:id","why":"Load one channel (name, description, branches) — call it before update_channel, which fully replaces name, description and branch list. Linkage: a channel id is referenced by list_selling_products data.products[].price_levels[].channel_id (per-channel price, product_channel_price), by orders (channel_id + channel_name, stored verbatim by sync_invoice — web-pos sends 0/\"\"), and by discount codes channel_ids (list_discounts / get_user_extras data.discount_code[]). Branch availability lives in channels_locations (location_id in these tools); master channels have channels.location_id NULL.","response":{"description":"A single object (no envelope). Errors: 400 { error: \"Invalid channel ID\" }; 404 { error: \"Channel not found\" } (also for another tenant's id); 403 { error: \"Access denied to this channel\" } when none of its branches is in the user's allow_locations; 500 { error: \"Failed to get channel\" }.","fields":{"id":"int. Channel id.","name":"string. Channel name.","description":"string | null. Free text.","customer_id":"int. Tenant id.","sku_no":"string | null. Global SKU \"chn-<customer_id>-<id>\".","location_id":"int | int[]. Branch(es) the channel is sold on, limited to the user's allowed branches. int when exactly one. Send it back as selected_location_ids on update_channel."},"example":{"id":463,"name":"Coffee Bar","description":"","customer_id":437,"sku_no":"chn-437-463","location_id":[909,908,907,922,910,1131]}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Channel id (list_channels [].id / list_selling_channels data[].channel_id)."}},"required":["id"]},"module":"channels","safety":"read"},{"name":"create_channel","description":"POST /api/channels. Why: Add a sales channel and assign it to branches. The POS (list_selling_channels) offers it at those branches; per-channel product prices are then set on products (update_product channel_price) and discounts can be limited to it (channel_ids). Check list_channels first — names must be unique. Linkage: a channel id is referenced by list_selling_products data.products[].price_levels[].channel_id (per-channel price, product_channel_price), by orders (channel_id + channel_name, stored verbatim by sync_invoice — web-pos sends 0/\"\"), and by discount codes channel_ids (list_discounts / get_user_extras data.discount_code[]). Branch availability lives in channels_locations (location_id in these tools); master channels have channels.location_id NULL. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_channel\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/channels","why":"Add a sales channel and assign it to branches. The POS (list_selling_channels) offers it at those branches; per-channel product prices are then set on products (update_product channel_price) and discounts can be limited to it (channel_ids). Check list_channels first — names must be unique. Linkage: a channel id is referenced by list_selling_products data.products[].price_levels[].channel_id (per-channel price, product_channel_price), by orders (channel_id + channel_name, stored verbatim by sync_invoice — web-pos sends 0/\"\"), and by discount codes channel_ids (list_discounts / get_user_extras data.discount_code[]). Branch availability lives in channels_locations (location_id in these tools); master channels have channels.location_id NULL.","response":{"description":"HTTP 201 { message, data } where data echoes the request body plus customer_id and id (not a DB re-read). Side effects: INSERT channels (location_id NULL) + channels_locations rows (no transaction) + queued global SKU. Errors: 400 { message: \"Please select at least one location\", status: 400 }; 409 { message: \"Channel with this name already exists\", status: 409 } (only master channels are checked); 500 { message: \"Internal Server Error\", error }.","fields":{"message":"string. \"Channel created successfully\" or the error text.","status":"int. Only on errors (400/409).","data.<each body field you sent>":"any. Echo of the request body.","data.customer_id":"int. Your tenant id.","data.id":"int. New channel id."},"example":{"message":"Channel created successfully","data":{"name":"Talabat","description":"Delivery app","selected_location_ids":[907,908],"customer_id":437,"id":516}}},"parameters":{"type":"object","properties":{"name":{"type":"string","description":"Channel name, max 100 chars."},"description":{"type":"string","description":"Free text, max 100 chars."},"selected_location_ids":{"type":"array","items":{"type":"number","description":""},"description":"Branch ids the channel is sold on (one channels_locations row each)."}},"required":["name","selected_location_ids"]},"module":"channels","safety":"write"},{"name":"update_channel","description":"PUT /api/channels/:id. Why: Rename a channel, change its description or the branches it is sold on. FULL REPLACE: name and description are both overwritten (omitted → NULL) and the branch list is replaced — call get_channel first and send everything back. Renaming also renames the channel on every product price row (price_levels[].channel_name); old orders keep the old channel_name. Linkage: a channel id is referenced by list_selling_products data.products[].price_levels[].channel_id (per-channel price, product_channel_price), by orders (channel_id + channel_name, stored verbatim by sync_invoice — web-pos sends 0/\"\"), and by discount codes channel_ids (list_discounts / get_user_extras data.discount_code[]). Branch availability lives in channels_locations (location_id in these tools); master channels have channels.location_id NULL. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_channel\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/channels/:id","why":"Rename a channel, change its description or the branches it is sold on. FULL REPLACE: name and description are both overwritten (omitted → NULL) and the branch list is replaced — call get_channel first and send everything back. Renaming also renames the channel on every product price row (price_levels[].channel_name); old orders keep the old channel_name. Linkage: a channel id is referenced by list_selling_products data.products[].price_levels[].channel_id (per-channel price, product_channel_price), by orders (channel_id + channel_name, stored verbatim by sync_invoice — web-pos sends 0/\"\"), and by discount codes channel_ids (list_discounts / get_user_extras data.discount_code[]). Branch availability lives in channels_locations (location_id in these tools); master channels have channels.location_id NULL.","response":{"description":"HTTP 200 { message: \"Channel updated successfully\", success: true } — also when the id does not exist or belongs to another tenant (nothing is checked). Errors: 409 { message: \"Channel with this name already exists\", status: 409 }; 500 { success: false, error }. Side effects (no transaction): UPDATE channels name+description; UPDATE product_channel_price.channel_name; DELETE + INSERT channels_locations.","fields":{"message":"string. \"Channel updated successfully\" or the error text.","success":"boolean. true on 200, false on 500.","status":"int. Only on 409.","error":"string. Only on 500."},"example":{"message":"Channel updated successfully","success":true}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Channel id (list_channels / get_channel)."},"name":{"type":"string","description":"Channel name."},"description":{"type":"string","description":"Always overwritten: omitted → NULL."},"selected_location_ids":{"type":"array","items":{"type":"number","description":""},"description":"The COMPLETE branch list after the update: all channels_locations rows are deleted and re-inserted from it."}},"required":["id","name","selected_location_ids"]},"module":"channels","safety":"write"},{"name":"delete_channel","description":"DELETE /api/channels/:id. Why: Hard-delete a channel and every product price for it (product_channel_price). No in-use check: orders keep their channel_id/channel_name, discounts keep the stale id in channel_ids, and channels_locations rows are left orphaned. Linkage: a channel id is referenced by list_selling_products data.products[].price_levels[].channel_id (per-channel price, product_channel_price), by orders (channel_id + channel_name, stored verbatim by sync_invoice — web-pos sends 0/\"\"), and by discount codes channel_ids (list_discounts / get_user_extras data.discount_code[]). Branch availability lives in channels_locations (location_id in these tools); master channels have channels.location_id NULL. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_channel\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/channels/:id","why":"Hard-delete a channel and every product price for it (product_channel_price). No in-use check: orders keep their channel_id/channel_name, discounts keep the stale id in channel_ids, and channels_locations rows are left orphaned. Linkage: a channel id is referenced by list_selling_products data.products[].price_levels[].channel_id (per-channel price, product_channel_price), by orders (channel_id + channel_name, stored verbatim by sync_invoice — web-pos sends 0/\"\"), and by discount codes channel_ids (list_discounts / get_user_extras data.discount_code[]). Branch availability lives in channels_locations (location_id in these tools); master channels have channels.location_id NULL.","response":{"description":"Always { status: true, message: \"Channels deleted successfully\" } — even when the id does not exist (not checked against your tenant).","fields":{"status":"boolean. Always true.","message":"string. \"Channels deleted successfully\"."},"example":{"status":true,"message":"Channels deleted successfully"}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Channel id (list_channels)."}},"required":["id"]},"module":"channels","safety":"destructive"},{"name":"list_menus","description":"GET /api/menus. Why: List the tenant's named menus (dashboard Products → Menus) with their branches, brand, routing label and Active status, to get ids for get_menu / update_menu / delete_menu. Menus are dashboard records only: nothing else in SysPOS reads them today. The POS (web-pos) never calls /menus, list_selling_products / list_selling_categories do not look at menus, and the aggregator syncs (Deliverect, Grubtech, Otter, LimeTray) build their menus from categories (sync_* flags, list_categories) and products, not from these menus. Adding or removing a product here does not change what a branch sells; for that use update_product (location_prices) or the category tools. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_menus\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/menus","why":"List the tenant's named menus (dashboard Products → Menus) with their branches, brand, routing label and Active status, to get ids for get_menu / update_menu / delete_menu. Menus are dashboard records only: nothing else in SysPOS reads them today. The POS (web-pos) never calls /menus, list_selling_products / list_selling_categories do not look at menus, and the aggregator syncs (Deliverect, Grubtech, Otter, LimeTray) build their menus from categories (sync_* flags, list_categories) and products, not from these menus. Adding or removing a product here does not change what a branch sells; for that use update_product (location_prices) or the category tools.","response":{"description":"200 → { success: true, data }. data = one object per menu: every menu column plus brand_name, label_name, tax_name, tax_rate and location_id rebuilt from menu_locations. ORDER BY name ASC, no paging. Errors → 500 { success: false, error: <SQL/JS message> }.","fields":{"success":"boolean. true on 200.","data":"array. All matching menus (no paging).","data[].id":"int. Menu id. Pass as `id` to get_menu / update_menu / delete_menu.","data[].name":"string. Menu name (trimmed on save).","data[].description":"string. Description; \"\" when none (never null for menus saved through the API).","data[].currency":"string. Currency code typed on the menu (dashboard choices AED / USD / EUR; server default \"AED\"). Label only: no amount is converted.","data[].customer_id":"int. Tenant (merchant) id. Always your own tenant; not needed.","data[].location_id":"int | int[] | null. Branches the menu is linked to (menu_locations, ids from list_locations): a bare int when exactly one, an array when several, null when none. The raw menu.location_id column (always null on master tenants) is replaced by this value.","data[].is_active":"0 | 1. 1 = Active, 0 = Inactive (dashboard Status pill / Active switch).","data[].brand_id":"int | null. Brand id (list_brands). Can point to a brand that no longer exists (then brand_name is null).","data[].label_id":"int | null. Routing label id (list_labels; dashboard \"Routing Label\").","data[].tax_id":"int | null. Tax id stored on the menu. Tax is out of scope: copy it unchanged into update_menu.","data[].brand_name":"string | null. Name of brand_id (LEFT JOIN brands); null when unset or the brand is gone.","data[].label_name":"string | null. Name of label_id.","data[].tax_name":"string | null. Name of tax_id. Out of scope.","data[].tax_rate":"number | null. Rate of tax_id in percent. Out of scope.","error":"string. Only on 500: the error message."},"example":{"success":true,"data":[{"id":4,"name":"classic menu","description":"","currency":"AED","customer_id":437,"location_id":[907,910,922,909,908],"is_active":1,"brand_id":414,"label_id":51,"tax_id":16,"brand_name":null,"label_name":"Coffee beans","tax_name":"Food Card Tax","tax_rate":5}]},"master_rules":["Dashboard Menus page calls this with every filter it holds: k (search box, \"\" when empty), page, limit (10; 5/10/25 selectable) and loc when the location filter is set. The server reads only loc: the search box and pagination do nothing server-side (the table renders every row it gets). To reproduce the page send loc only and filter by name yourself.","Each row shows: Name, Description, Brand (brand_name or \"-\"), Currency (\"AED\" when empty), Tax (\"tax_name (tax_rate%)\"), Label (label_name), Location (\"1 location\" / \"<n> location(s)\" from location_id, \"-\" when null), Status Active/Inactive from is_active, and View / Edit / Delete actions (permission-gated).","location_id ids are branch ids from list_locations; with `loc` it only lists the requested branches, so use get_menu → locations for the full branch list.","Menus are dashboard records only: nothing else in SysPOS reads them today. The POS (web-pos) never calls /menus, list_selling_products / list_selling_categories do not look at menus, and the aggregator syncs (Deliverect, Grubtech, Otter, LimeTray) build their menus from categories (sync_* flags, list_categories) and products, not from these menus. Adding or removing a product here does not change what a branch sells; for that use update_product (location_prices) or the category tools."]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id or comma-separated branch ids (list_locations)."}},"required":[]},"module":"menus","safety":"read"},{"name":"get_menu","description":"GET /api/menus/:id. Why: Load one menu with its categories, branches and products — the dashboard View and Edit pages. Call it before update_menu (the update overwrites every field) and to see which products/categories a menu contains. Menus are dashboard records only: nothing else in SysPOS reads them today. The POS (web-pos) never calls /menus, list_selling_products / list_selling_categories do not look at menus, and the aggregator syncs (Deliverect, Grubtech, Otter, LimeTray) build their menus from categories (sync_* flags, list_categories) and products, not from these menus. Adding or removing a product here does not change what a branch sells; for that use update_product (location_prices) or the category tools. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_menu\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/menus/:id","why":"Load one menu with its categories, branches and products — the dashboard View and Edit pages. Call it before update_menu (the update overwrites every field) and to see which products/categories a menu contains. Menus are dashboard records only: nothing else in SysPOS reads them today. The POS (web-pos) never calls /menus, list_selling_products / list_selling_categories do not look at menus, and the aggregator syncs (Deliverect, Grubtech, Otter, LimeTray) build their menus from categories (sync_* flags, list_categories) and products, not from these menus. Adding or removing a product here does not change what a branch sells; for that use update_product (location_prices) or the category tools.","response":{"description":"200 → { success: true, data } with the list_menus row plus three child arrays. 404 → { success: false, error: \"Menu not found\" }. 500 → { success: false, error }.","fields":{"success":"boolean. true on 200, false on 404/500.","data":"object. The menu.","data.id":"int. Menu id. Pass as `id` to get_menu / update_menu / delete_menu.","data.name":"string. Menu name (trimmed on save).","data.description":"string. Description; \"\" when none (never null for menus saved through the API).","data.currency":"string. Currency code typed on the menu (dashboard choices AED / USD / EUR; server default \"AED\"). Label only: no amount is converted.","data.customer_id":"int. Tenant (merchant) id. Always your own tenant; not needed.","data.location_id":"int | int[] | null. Branches the menu is linked to (menu_locations, ids from list_locations): a bare int when exactly one, an array when several, null when none. The raw menu.location_id column (always null on master tenants) is replaced by this value.","data.is_active":"0 | 1. 1 = Active, 0 = Inactive (dashboard Status pill / Active switch).","data.brand_id":"int | null. Brand id (list_brands). Can point to a brand that no longer exists (then brand_name is null).","data.label_id":"int | null. Routing label id (list_labels; dashboard \"Routing Label\").","data.tax_id":"int | null. Tax id stored on the menu. Tax is out of scope: copy it unchanged into update_menu.","data.brand_name":"string | null. Name of brand_id (LEFT JOIN brands); null when unset or the brand is gone.","data.label_name":"string | null. Name of label_id.","data.tax_name":"string | null. Name of tax_id. Out of scope.","data.tax_rate":"number | null. Rate of tax_id in percent. Out of scope.","data.categories":"array. Categories on the menu (menu_categories rows).","data.categories[].id":"int. menu_categories link-row id (not a category id). Not needed.","data.categories[].menu_id":"int. This menu's id.","data.categories[].category_id":"int. Category id (list_categories / get_category). Resend these as update_menu category_id.","data.categories[].category_name":"string | null. Category name; null when the category no longer exists.","data.categories[].category_description":"string | null. Category description.","data.locations":"array. Branches the menu is linked to (menu_locations rows); same ids as location_id.","data.locations[].id":"int. menu_locations link-row id. Not needed.","data.locations[].menu_id":"int. This menu's id.","data.locations[].location_id":"int. Branch id (list_locations). Resend these as update_menu location_id.","data.locations[].customer_id":"int. Tenant id; not needed.","data.locations[].location_name":"string | null. Branch name.","data.products":"array. Products on the menu (menu_products rows), ordered by category_id then row id.","data.products[].id":"int. menu_products row id (not a product id). Not needed.","data.products[].name":"string | null. Name stored on this menu row when it was added (not refreshed from the catalog).","data.products[].actual_price":"number | null. Price stored on this menu row when it was added (not refreshed; not used for selling).","data.products[].product_id":"int. Product id (list_products / get_product). Resend in products_with_categories on update.","data.products[].customer_id":"int. Tenant id; not needed.","data.products[].location_id":"null. Always null on master tenants. Not needed.","data.products[].menu_id":"int. This menu's id.","data.products[].category_id":"int | null. Category the product is filed under on this menu.","data.products[].product_name":"string | null. Current catalog name (LEFT JOIN products); null when the product no longer exists — then the product is a stale entry.","data.products[].product_description":"string | null. Current catalog description.","data.products[].product_image":"string | null. Catalog image value (raw column, may be a relative path).","data.products[].category_name":"string | null. Name of products[].category_id.","error":"string. Only on 404/500: \"Menu not found\" or the error message."},"example":{"success":true,"data":{"id":4,"name":"classic menu","description":"","currency":"AED","customer_id":437,"location_id":[907,910,922,909,908],"is_active":1,"brand_id":414,"label_id":51,"tax_id":16,"brand_name":null,"label_name":"Coffee beans","tax_name":"Food Card Tax","tax_rate":5,"categories":[{"id":4,"menu_id":4,"category_id":15481,"category_name":"Alternate Options","category_description":null}],"products":[{"id":8,"name":"Chai","actual_price":13,"product_id":247653,"customer_id":437,"location_id":null,"menu_id":4,"category_id":15481,"product_name":null,"product_description":null,"product_image":null,"category_name":"Alternate Options"}],"locations":[{"id":34,"menu_id":4,"location_id":907,"customer_id":437,"location_name":"Default location"},{"id":51,"menu_id":4,"location_id":910,"customer_id":437,"location_name":"Bahira Town"},{"id":52,"menu_id":4,"location_id":922,"customer_id":437,"location_name":"Park View City"},{"id":53,"menu_id":4,"location_id":909,"customer_id":437,"location_name":"Iqbal Town"},{"id":54,"menu_id":4,"location_id":908,"customer_id":437,"location_name":"Johar Town"}]}},"master_rules":["Dashboard View page (Eye icon) shows name, description, brand_name, the branch chips (locations[].location_name), label_name, currency, tax_name (tax_rate%), then per category (categories[]) the products whose category_id matches, each as \"<name or product_name> — <actual_price> <currency>\".","Dashboard Edit page loads this and pre-fills the form: category_id ← categories[].category_id, location_id ← locations[].location_id, product_id ← products[].product_id, plus name/description/currency/brand_id/label_id/tax_id/is_active. Build update_menu the same way.","products[].actual_price is a snapshot, not the selling price: for real branch prices use list_selling_products (branch) or get_product → location_prices. A product with product_name null has been removed from the catalog; drop it on the next update_menu.","Menus are dashboard records only: nothing else in SysPOS reads them today. The POS (web-pos) never calls /menus, list_selling_products / list_selling_categories do not look at menus, and the aggregator syncs (Deliverect, Grubtech, Otter, LimeTray) build their menus from categories (sync_* flags, list_categories) and products, not from these menus. Adding or removing a product here does not change what a branch sells; for that use update_product (location_prices) or the category tools."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Menu id (list_menus data[].id)."}},"required":["id"]},"module":"menus","safety":"read"},{"name":"create_menu","description":"POST /api/menus. Why: Create a named menu: a name plus branches (list_locations), categories (list_categories) and products (list_products) with an optional stored price per product. Menus are dashboard records only: nothing else in SysPOS reads them today. The POS (web-pos) never calls /menus, list_selling_products / list_selling_categories do not look at menus, and the aggregator syncs (Deliverect, Grubtech, Otter, LimeTray) build their menus from categories (sync_* flags, list_categories) and products, not from these menus. Adding or removing a product here does not change what a branch sells; for that use update_product (location_prices) or the category tools. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_menu\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/menus","why":"Create a named menu: a name plus branches (list_locations), categories (list_categories) and products (list_products) with an optional stored price per product. Menus are dashboard records only: nothing else in SysPOS reads them today. The POS (web-pos) never calls /menus, list_selling_products / list_selling_categories do not look at menus, and the aggregator syncs (Deliverect, Grubtech, Otter, LimeTray) build their menus from categories (sync_* flags, list_categories) and products, not from these menus. Adding or removing a product here does not change what a branch sells; for that use update_product (location_prices) or the category tools.","response":{"description":"201 → { success: true, message: \"Menu created successfully\", menu_id, data }. 400 → { success: false, error: \"Menu name is required\" } when name is missing/blank. 500 → { success: false, error: <message> } (DB error); the menu row may already exist — check list_menus.","fields":{"success":"boolean. true on 201, false on 400/500.","message":"string. \"Menu created successfully\" on 201.","menu_id":"int. New menu id (use with get_menu / update_menu / delete_menu).","data":"object. The menu re-read after the write (same row as list_menus plus two short child lists below). Not the get_menu shape.","data.id":"int. Menu id. Pass as `id` to get_menu / update_menu / delete_menu.","data.name":"string. Menu name (trimmed on save).","data.description":"string. Description; \"\" when none (never null for menus saved through the API).","data.currency":"string. Currency code typed on the menu (dashboard choices AED / USD / EUR; server default \"AED\"). Label only: no amount is converted.","data.customer_id":"int. Tenant (merchant) id. Always your own tenant; not needed.","data.location_id":"int | int[] | null. Branches the menu is linked to (menu_locations, ids from list_locations): a bare int when exactly one, an array when several, null when none. The raw menu.location_id column (always null on master tenants) is replaced by this value.","data.is_active":"0 | 1. 1 = Active, 0 = Inactive (dashboard Status pill / Active switch).","data.brand_id":"int | null. Brand id (list_brands). Can point to a brand that no longer exists (then brand_name is null).","data.label_id":"int | null. Routing label id (list_labels; dashboard \"Routing Label\").","data.tax_id":"int | null. Tax id stored on the menu. Tax is out of scope: copy it unchanged into update_menu.","data.brand_name":"string | null. Name of brand_id (LEFT JOIN brands); null when unset or the brand is gone.","data.label_name":"string | null. Name of label_id.","data.tax_name":"string | null. Name of tax_id. Out of scope.","data.tax_rate":"number | null. Rate of tax_id in percent. Out of scope.","data.categories":"array. Categories on the menu (menu_categories).","data.categories[].category_id":"int. Category id (list_categories).","data.categories[].category_name":"string | null. Category name; null when the category no longer exists.","data.products":"array. Products on the menu (menu_products).","data.products[].product_id":"int. Product id (list_products).","data.products[].category_id":"int | null. Category the product is filed under on this menu.","data.products[].actual_price":"number | null. Price stored on this menu row.","data.products[].product_name":"string | null. Current catalog name of the product; null when the product no longer exists.","data.products[].product_type":"string | null. Catalog product.type (category name(s)).","error":"string. Only on 400/500: \"Menu name is required\" or the error message."},"example_request":{"name":"Breakfast Menu","description":"Served until noon","currency":"AED","is_active":1,"brand_id":null,"label_id":51,"location_id":[907,910],"category_id":[15481],"products_with_categories":[{"product_id":247653,"name":"Chai","actual_price":13,"category_id":15481}]},"example":{"success":true,"message":"Menu created successfully","menu_id":5,"data":{"id":5,"name":"Breakfast Menu","description":"Served until noon","currency":"AED","customer_id":437,"location_id":[907,910],"is_active":1,"brand_id":null,"label_id":51,"tax_id":null,"brand_name":null,"label_name":"Coffee beans","tax_name":null,"tax_rate":null,"categories":[{"category_id":15481,"category_name":"Alternate Options"}],"products":[{"product_id":247653,"category_id":15481,"actual_price":13,"product_name":"Chai","product_type":"Alternate Options"}]}},"master_rules":["Dashboard form (Create Menu / Edit Menu pages, one form with two tabs). Settings tab: Menu name *, Currency (AED/USD/EUR, default AED), Description, Brand, Routing Label, Tax Group + Tax (tax out of scope), Locations multi-select, Active switch (default on). Products tab: Categories multi-select, then products (the product picker loads list_products with cat = the selected category NAMES and limit 1000). Validation before sending: name 2–300 chars, description ≤ 300, currency required, at least one location, one category and one product. Apply the same checks before calling.","The dashboard body is { name, description, currency, brand_id, label_id, tax_group_id, tax_id, category_id[], location_id[], product_id[], is_active, products_with_categories[] } (update also repeats id). The server ignores tax_group_id and the body id; MCP does not send them.","There are no per-menu availability days/hours, channels or sort order: the menu table has none. Opening hours live on the branch (get_business_hours / get_location_days_hours), channel prices on products (list_channels / list_selling_channels), category order on categories (menu_sort_order via update_category). Per-menu prices exist only as the stored actual_price of products_with_categories and are not used for selling.","Menus are dashboard records only: nothing else in SysPOS reads them today. The POS (web-pos) never calls /menus, list_selling_products / list_selling_categories do not look at menus, and the aggregator syncs (Deliverect, Grubtech, Otter, LimeTray) build their menus from categories (sync_* flags, list_categories) and products, not from these menus. Adding or removing a product here does not change what a branch sells; for that use update_product (location_prices) or the category tools.","Deals/combos (list_deals) are combo products: the dashboard product picker (list_products) excludes them, and nothing links menus to deals.","Create Menu button (permission products.menus.add) → form → POST; on success the dashboard shows \"Menu created successfully!\" and returns to the list. Check success === true, then confirm with get_menu(menu_id).","Always send location_id as an array of branch ids; a menu created without branches is invisible to list_menus whenever `loc` is set (always, for a branch-scoped key)."]},"parameters":{"type":"object","properties":{"name":{"type":"string","description":"Menu name."},"description":{"type":"string","description":"Free-text description."},"currency":{"type":"string","description":"Currency code label."},"is_active":{"type":"number","description":"1 = Active, 0 = Inactive."},"brand_id":{"type":"number","description":"Brand id (list_brands)."},"label_id":{"type":"number","description":"Routing label id (list_labels)."},"tax_id":{"type":"number","description":"Tax id."},"location_id":{"type":"array","items":{"type":"number","description":"Branch id (list_locations)."},"description":"Branches the menu is linked to (menu_locations), as an ARRAY of ids."},"category_id":{"type":"array","items":{"type":"number","description":"Category id (list_categories)."},"description":"Categories (menu sections) on the menu (menu_categories)."},"product_id":{"type":"array","items":{"type":"number","description":"Product id (list_products)."},"description":"Products on the menu, used ONLY when products_with_categories is omitted or []."},"products_with_categories":{"type":"array","items":{"type":"object","properties":{"product_id":{"type":"number","description":"Product id (list_products)."},"name":{"type":"string","description":"Name stored on this menu row."},"actual_price":{"type":"number","description":"Price stored on this menu row (VAT-inclusive like actual_price)."},"category_id":{"type":"number","description":"Category (list_categories) the product is shown under on this menu; should be one of category_id."}},"required":["product_id"],"description":""},"description":"Explicit product rows; when non-empty it wins over product_id."}},"required":["name","location_id"]},"module":"menus","safety":"write"},{"name":"update_menu","description":"PUT /api/menus/:id. Why: Edit a menu: rename, (de)activate, change branches, categories or products. It is a full replace, not a patch — load get_menu first and send every field back. Menus are dashboard records only: nothing else in SysPOS reads them today. The POS (web-pos) never calls /menus, list_selling_products / list_selling_categories do not look at menus, and the aggregator syncs (Deliverect, Grubtech, Otter, LimeTray) build their menus from categories (sync_* flags, list_categories) and products, not from these menus. Adding or removing a product here does not change what a branch sells; for that use update_product (location_prices) or the category tools. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_menu\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/menus/:id","why":"Edit a menu: rename, (de)activate, change branches, categories or products. It is a full replace, not a patch — load get_menu first and send every field back. Menus are dashboard records only: nothing else in SysPOS reads them today. The POS (web-pos) never calls /menus, list_selling_products / list_selling_categories do not look at menus, and the aggregator syncs (Deliverect, Grubtech, Otter, LimeTray) build their menus from categories (sync_* flags, list_categories) and products, not from these menus. Adding or removing a product here does not change what a branch sells; for that use update_product (location_prices) or the category tools.","response":{"description":"200 → { success: true, message: \"Menu updated successfully\", menu_id, data }. 404 → { success: false, error: \"Menu not found or access denied\" } (id not yours). 400 → { success: false, error: \"Menu name is required\" }. 500 → { success: false, error }. Not transactional: a 500 midway can leave categories/branches already replaced.","fields":{"success":"boolean. true on 200, false on 400/404/500.","message":"string. \"Menu updated successfully\" on 200.","menu_id":"string. The path id echoed back as a STRING (e.g. \"4\").","data":"object. The menu re-read after the write (same row as list_menus plus two short child lists below). Not the get_menu shape.","data.id":"int. Menu id. Pass as `id` to get_menu / update_menu / delete_menu.","data.name":"string. Menu name (trimmed on save).","data.description":"string. Description; \"\" when none (never null for menus saved through the API).","data.currency":"string. Currency code typed on the menu (dashboard choices AED / USD / EUR; server default \"AED\"). Label only: no amount is converted.","data.customer_id":"int. Tenant (merchant) id. Always your own tenant; not needed.","data.location_id":"int | int[] | null. Branches the menu is linked to (menu_locations, ids from list_locations): a bare int when exactly one, an array when several, null when none. The raw menu.location_id column (always null on master tenants) is replaced by this value.","data.is_active":"0 | 1. 1 = Active, 0 = Inactive (dashboard Status pill / Active switch).","data.brand_id":"int | null. Brand id (list_brands). Can point to a brand that no longer exists (then brand_name is null).","data.label_id":"int | null. Routing label id (list_labels; dashboard \"Routing Label\").","data.tax_id":"int | null. Tax id stored on the menu. Tax is out of scope: copy it unchanged into update_menu.","data.brand_name":"string | null. Name of brand_id (LEFT JOIN brands); null when unset or the brand is gone.","data.label_name":"string | null. Name of label_id.","data.tax_name":"string | null. Name of tax_id. Out of scope.","data.tax_rate":"number | null. Rate of tax_id in percent. Out of scope.","data.categories":"array. Categories on the menu (menu_categories).","data.categories[].category_id":"int. Category id (list_categories).","data.categories[].category_name":"string | null. Category name; null when the category no longer exists.","data.products":"array. Products on the menu (menu_products).","data.products[].product_id":"int. Product id (list_products).","data.products[].category_id":"int | null. Category the product is filed under on this menu.","data.products[].actual_price":"number | null. Price stored on this menu row.","data.products[].product_name":"string | null. Current catalog name of the product; null when the product no longer exists.","data.products[].product_type":"string | null. Catalog product.type (category name(s)).","error":"string. Only on 400/404/500: \"Menu name is required\", \"Menu not found or access denied\" or the error message."},"example_request":{"name":"classic menu","description":"","currency":"AED","is_active":1,"brand_id":414,"label_id":51,"tax_id":16,"location_id":[907,910,922,909,908],"category_id":[15481],"products_with_categories":[{"product_id":247653,"name":"Chai","actual_price":13,"category_id":15481}]},"example":{"success":true,"message":"Menu updated successfully","menu_id":"4","data":{"id":4,"name":"classic menu","description":"","currency":"AED","customer_id":437,"location_id":[907,910,922,909,908],"is_active":1,"brand_id":414,"label_id":51,"tax_id":16,"brand_name":null,"label_name":"Coffee beans","tax_name":"Food Card Tax","tax_rate":5,"categories":[{"category_id":15481,"category_name":"Alternate Options"}],"products":[{"product_id":247653,"category_id":15481,"actual_price":13,"product_name":null,"product_type":null}]}},"master_rules":["Dashboard form (Create Menu / Edit Menu pages, one form with two tabs). Settings tab: Menu name *, Currency (AED/USD/EUR, default AED), Description, Brand, Routing Label, Tax Group + Tax (tax out of scope), Locations multi-select, Active switch (default on). Products tab: Categories multi-select, then products (the product picker loads list_products with cat = the selected category NAMES and limit 1000). Validation before sending: name 2–300 chars, description ≤ 300, currency required, at least one location, one category and one product. Apply the same checks before calling.","The dashboard body is { name, description, currency, brand_id, label_id, tax_group_id, tax_id, category_id[], location_id[], product_id[], is_active, products_with_categories[] } (update also repeats id). The server ignores tax_group_id and the body id; MCP does not send them.","There are no per-menu availability days/hours, channels or sort order: the menu table has none. Opening hours live on the branch (get_business_hours / get_location_days_hours), channel prices on products (list_channels / list_selling_channels), category order on categories (menu_sort_order via update_category). Per-menu prices exist only as the stored actual_price of products_with_categories and are not used for selling.","Menus are dashboard records only: nothing else in SysPOS reads them today. The POS (web-pos) never calls /menus, list_selling_products / list_selling_categories do not look at menus, and the aggregator syncs (Deliverect, Grubtech, Otter, LimeTray) build their menus from categories (sync_* flags, list_categories) and products, not from these menus. Adding or removing a product here does not change what a branch sells; for that use update_product (location_prices) or the category tools.","Deals/combos (list_deals) are combo products: the dashboard product picker (list_products) excludes them, and nothing links menus to deals.","Edit (pencil) on the Menus list → Edit page loads get_menu, pre-fills the form, and PUTs the whole form; on success it returns to the list. Do the same: get_menu → change only what the user asked → send name, description, currency, is_active, brand_id, label_id, tax_id, location_id (locations[].location_id), category_id (categories[].category_id) and products_with_categories (one row per products[]: product_id, name, actual_price, category_id).","To deactivate a menu send is_active: 0 with the full body; leaving is_active out re-activates it.","The stored name/actual_price of a product already on the menu cannot be changed by update_menu (only its category_id). To re-price it, remove it in one update and add it back in a second update."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Menu id (list_menus data[].id)."},"name":{"type":"string","description":"Menu name."},"description":{"type":"string","description":"Free-text description."},"currency":{"type":"string","description":"Currency code label."},"is_active":{"type":"number","description":"1 = Active, 0 = Inactive."},"brand_id":{"type":"number","description":"Brand id (list_brands)."},"label_id":{"type":"number","description":"Routing label id (list_labels)."},"tax_id":{"type":"number","description":"Tax id."},"location_id":{"type":"array","items":{"type":"number","description":"Branch id (list_locations)."},"description":"Branches the menu is linked to (menu_locations), as an ARRAY of ids."},"category_id":{"type":"array","items":{"type":"number","description":"Category id (list_categories)."},"description":"Categories (menu sections) on the menu (menu_categories)."},"product_id":{"type":"array","items":{"type":"number","description":"Product id (list_products)."},"description":"Products on the menu, used ONLY when products_with_categories is omitted or []."},"products_with_categories":{"type":"array","items":{"type":"object","properties":{"product_id":{"type":"number","description":"Product id (list_products)."},"name":{"type":"string","description":"Name stored on this menu row."},"actual_price":{"type":"number","description":"Price stored on this menu row (VAT-inclusive like actual_price)."},"category_id":{"type":"number","description":"Category (list_categories) the product is shown under on this menu; should be one of category_id."}},"required":["product_id"],"description":""},"description":"Explicit product rows; when non-empty it wins over product_id."}},"required":["id","name","location_id"]},"module":"menus","safety":"write"},{"name":"delete_menu","description":"DELETE /api/menus/:id. Why: Permanently delete a menu and its category, branch and product links (hard delete, no restore). Products and categories themselves are not touched. Prefer update_menu with is_active: 0 to hide it. Menus are dashboard records only: nothing else in SysPOS reads them today. The POS (web-pos) never calls /menus, list_selling_products / list_selling_categories do not look at menus, and the aggregator syncs (Deliverect, Grubtech, Otter, LimeTray) build their menus from categories (sync_* flags, list_categories) and products, not from these menus. Adding or removing a product here does not change what a branch sells; for that use update_product (location_prices) or the category tools. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_menu\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/menus/:id","why":"Permanently delete a menu and its category, branch and product links (hard delete, no restore). Products and categories themselves are not touched. Prefer update_menu with is_active: 0 to hide it. Menus are dashboard records only: nothing else in SysPOS reads them today. The POS (web-pos) never calls /menus, list_selling_products / list_selling_categories do not look at menus, and the aggregator syncs (Deliverect, Grubtech, Otter, LimeTray) build their menus from categories (sync_* flags, list_categories) and products, not from these menus. Adding or removing a product here does not change what a branch sells; for that use update_product (location_prices) or the category tools.","response":{"description":"200 → { success: true, message: \"Menu deleted successfully\" } always (no existence check) — verify with list_menus / get_menu (404). 500 → { success: false, error }.","fields":{"success":"boolean. true on 200 (not proof the menu existed), false on 500.","message":"string. \"Menu deleted successfully\" on 200.","error":"string. Only on 500: the error message."},"example":{"success":true,"message":"Menu deleted successfully"},"master_rules":["Dashboard: trash icon on a Menus list row (permission products.menus.delete) → confirm dialog → DELETE → toast with message → list refresh. Ask the user to confirm the same way, naming the menu.","After deleting, get_menu(id) must return 404. There is no undo; to hide a menu temporarily use update_menu is_active: 0.","Menus are dashboard records only: nothing else in SysPOS reads them today. The POS (web-pos) never calls /menus, list_selling_products / list_selling_categories do not look at menus, and the aggregator syncs (Deliverect, Grubtech, Otter, LimeTray) build their menus from categories (sync_* flags, list_categories) and products, not from these menus. Adding or removing a product here does not change what a branch sells; for that use update_product (location_prices) or the category tools."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Menu id (list_menus data[].id)."}},"required":["id"]},"module":"menus","safety":"destructive"},{"name":"list_selling_coupons","description":"GET /api/v2/coupons. Why: Coupon list exactly as web-pos loads it into its local coupon cache when the Coupon dialog opens. Look the typed/scanned code up here FIRST (case-insensitive exact match on code): it tells you whether the coupon is product-scoped (product_ids non-empty → send product_ids to validate_coupon) or basket-wide (send order_total), and gives discount_type / discount_value to compute the discount — validate_coupon does not return an amount. Then ALWAYS call validate_coupon before applying. Not paginated. Product ids = list_selling_products data.products[].id; customer = list_pos_crm_users. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_selling_coupons\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/coupons","why":"Coupon list exactly as web-pos loads it into its local coupon cache when the Coupon dialog opens. Look the typed/scanned code up here FIRST (case-insensitive exact match on code): it tells you whether the coupon is product-scoped (product_ids non-empty → send product_ids to validate_coupon) or basket-wide (send order_total), and gives discount_type / discount_value to compute the discount — validate_coupon does not return an amount. Then ALWAYS call validate_coupon before applying. Not paginated. Product ids = list_selling_products data.products[].id; customer = list_pos_crm_users.","response":{"description":"{ status, message, data[] }. One row per coupon (GROUP BY id), unsorted (id order in practice). Includes inactive, expired and exhausted coupons — the list is a lookup table, not an 'available now' list: validity is decided by validate_coupon. Error: HTTP 500 { status:false, message, data:null }.","fields":{"status":"boolean. true = success (also when the list is empty). false = server error (HTTP 500).","message":"string. \"All coupons retrieved successfully.\" on success.","data":"array. Coupons. [] = none (or none linked to loc).","data[].id":"int. Coupon id. USE: coupon_id on the order (header for a basket coupon, item row for a product-scoped one).","data[].code":"string. THE KEY the cashier types/scans. Match it case-insensitively after trimming. USE: coupon_code for validate_coupon and on the order.","data[].description":"string | null. Coupon label shown in the dashboard. NOT USED by web-pos (it shows the upper-cased code). Optional to show.","data[].discount_type":"string. \"percentage\" = discount_value is a percent; ANY other value (dashboard stores \"value\") = discount_value is a fixed amount. Compared case-insensitively. USE to compute the discount.","data[].discount_value":"number | null. Percent (e.g. 10 = 10%) or fixed amount in currency, per discount_type. USE to compute the discount.","data[].product_ids":"int[]. Products the coupon is limited to (list_selling_products data.products[].id). Non-empty = PRODUCT-SCOPED coupon: web-pos sends product_ids to validate_coupon and discounts ONE line. [] = basket coupon.","data[].category_ids":"int[]. Categories the coupon was linked to in the dashboard (list_selling_categories ids). NOT ENFORCED anywhere: neither validate_coupon nor web-pos reads it — a coupon with only category_ids behaves as a BASKET coupon.","data[].min_order_value":"number | null. Minimum basket the dashboard intended. NOT ENFORCED by validate_coupon nor web-pos. Check order_total >= min_order_value yourself before applying.","data[].start_date":"string (ISO datetime) | null. Valid from (compared with the database clock). null = no start. Enforced by validate_coupon.","data[].end_date":"string (ISO datetime) | null. Valid until this exact instant. A date saved as YYYY-MM-DD means midnight at the START of that day, so the coupon already fails on that day. null = no end. Enforced by validate_coupon.","data[].is_active":"0 | 1 | null. Only 1 validates. web-pos does not filter on it (the server rejects it).","data[].usage_limit":"int | null. Total redemptions allowed across all customers. null/0 = unlimited. Enforced by validate_coupon against current_usage.","data[].current_usage":"int. Redemptions counted so far (incremented by sync_invoice for each saved invoice carrying coupon_id; only while usage_limit is set).","data[].per_user_limit":"int | null. Redemptions allowed per CRM customer (counted from that customer's invoices with this coupon_code). null/0 = unlimited. Enforced by validate_coupon ONLY when crm_user_id is sent.","data[].location_ids":"int[]. Branches the coupon is linked to (coupons_locations). The coupon validates only at these branches. [] = linked to no branch → never validates on a master tenant.","data[].location_id":"int | int[] | null. Same branches as location_ids: one id when exactly one branch, an array when several, null when none. Prefer location_ids.","data[].customer_id":"int. NOT USED — tenant id.","data[].created_at":"string (ISO datetime). NOT USED — created time.","data[].updated_at":"string (ISO datetime). NOT USED — last update time."},"master_rules":["Coupon flow (web-pos CouponDialog): refresh this list → find the entered code in it (trim, case-insensitive) → unknown code = reject ('invalid, resync') without calling the server → call validate_coupon → apply the discount yourself from discount_type/discount_value. validate_coupon returns NO discount amount.","One coupon per order: refuse a second coupon while one is applied (on the order header or on any line).","Coupons are NOT discount codes. Discount codes are preset named discounts from get_user_extras data.discount_code[] (managed with list_discounts), picked from a list, checked client-side and stored in the order discount fields. Coupons are typed/scanned codes, checked by validate_coupon, usage-counted, and stored in coupon_id / coupon_code / coupon_amount. web-pos shows both only when the POS user setting discount_codes is on.","Basket coupon (product_ids = []): discount % = discount_value if percentage, else discount_value / basket × 100; amount = basket × discount_value / 100 if percentage, else discount_value (rounded to 0 dp only for 0-decimal currencies). Basket = sum of active cart line totals (after line discounts, VAT included) + service charge + delivery charge. It becomes the ORDER discount %: it REPLACES any manual order discount or discount code already on the order (web-pos asks the cashier to reset it first). Clearing the order discount removes the coupon too.","Product-scoped coupon (product_ids non-empty): discount ONE cart line — the one whose product_id = validate_coupon target_product_id. That line's qty must be exactly 1 (else refuse). A fixed coupon larger than the line's unit price is refused. The coupon % (discount_value, or discount_value / unit price × 100) is ADDED to the line's existing discount_pct, capped at 100.","Order fields (sync_invoice): basket coupon → invoice header coupon_id = data[].id, coupon_code = data[].code, coupon_amount = the basket amount above, plus the order discount % in discountpct. Product-scoped coupon → the target item row carries coupon_id, coupon_code and coupon_amount (unit price × new line discount % / 100) with the line discount_pct; header coupon fields stay 0/\"\" (the server copies them up). Always send crm_user_id on the order when a customer is attached — per-customer limits count that customer's invoices.","Loyalty burn is separate: validate_loyalty_burn runs at payment against the payable AFTER the coupon discount. Both may be used on one order."],"caveats":"No channel scope, no maximum-discount cap and no customer requirement exist for coupons. min_order_value and category_ids are stored but not enforced. The list is tenant-wide unless loc is passed.","example":{"status":true,"message":"All coupons retrieved successfully.","data":[{"id":987,"customer_id":437,"location_id":null,"code":"MASTER50","description":"MASTER50","discount_type":"value","discount_value":10,"min_order_value":10,"start_date":"2026-04-01T00:00:00.000Z","end_date":"2026-04-30T00:00:00.000Z","usage_limit":10,"per_user_limit":2,"is_active":1,"created_at":"2026-04-01T06:56:40.000Z","updated_at":"2026-04-01T06:56:40.000Z","current_usage":0,"location_ids":[],"product_ids":[],"category_ids":[]}]}},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Optional."},"code":{"type":"string","description":"Optional."},"is_active":{"type":"string","enum":["0","1"],"description":"Optional."},"discount_type":{"type":"string","enum":["percentage","value"],"description":"Optional."},"min_order_value":{"type":"number","description":"Optional."},"start_date":{"type":"string","description":"Optional."},"end_date":{"type":"string","description":"Optional."}},"required":[]},"module":"coupons","safety":"read"},{"name":"validate_coupon","description":"POST /api/v2/coupons/validate. Why: Server check of a coupon code before you apply it — what web-pos calls after finding the code in list_selling_coupons. Checks: code exists for the tenant, linked to location_id, is_active = 1, now within start_date/end_date, total usage_limit, per-customer per_user_limit (only if crm_user_id is sent), fixed/percentage discount not larger than order_total (basket mode), and for product-scoped coupons that the basket holds a linked product. Returns the coupon row and, for product-scoped coupons, target_product_id. Does NOT return a discount amount and does NOT consume the coupon (usage is counted when sync_invoice saves the order). No side effects. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"validate_coupon\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/v2/coupons/validate","why":"Server check of a coupon code before you apply it — what web-pos calls after finding the code in list_selling_coupons. Checks: code exists for the tenant, linked to location_id, is_active = 1, now within start_date/end_date, total usage_limit, per-customer per_user_limit (only if crm_user_id is sent), fixed/percentage discount not larger than order_total (basket mode), and for product-scoped coupons that the basket holds a linked product. Returns the coupon row and, for product-scoped coupons, target_product_id. Does NOT return a discount amount and does NOT consume the coupon (usage is counted when sync_invoice saves the order). No side effects.","response":{"description":"Valid: HTTP 200 { status:true, message:\"Coupon is valid.\", target_product_id?, data: coupon }. Invalid: HTTP 400 { status:false, message, data?: coupon } — message is the reason to show the cashier: \"Coupon code is required.\" | \"Order total is required and cannot be negative.\" | \"Invalid or expired Coupon\" (unknown code, not linked to location_id, inactive, before start_date or after end_date; no data) | \"Maximum usage limit reached.\" | \"Maximum usage limit reached for this user.\" | \"Order total must be at least <amount>.\" | \"Coupon valid for specific products only. Valid products:\\n<names>\" | \"No eligible products in order. Valid products:\\n<names>\". Server error: HTTP 500 { status:false, message, data:null }.","fields":{"status":"boolean. true = apply the coupon. false = do not apply; show message.","message":"string. \"Coupon is valid.\" or the rejection reason (see description).","target_product_id":"int. ONLY when product_ids was sent. Product id of the cart line to discount (first sent id linked to the coupon). Absent for basket coupons.","data":"object. The coupon row (on success and on most rejections). Values come from the database; apply the discount from these fields.","data.id":"int. Coupon id. USE: coupon_id on the order (header or target item row).","data.code":"string. Code as stored (original case). USE: coupon_code on the order.","data.discount_type":"string. \"percentage\" = percent; anything else (\"value\") = fixed amount. USE to compute the discount.","data.discount_value":"number | null. Percent or fixed amount. USE to compute the discount (see list_selling_coupons rules).","data.min_order_value":"number | null. NOT checked by the server. Check order_total >= it yourself.","data.usage_limit":"int | null. Total redemptions allowed; null = unlimited.","data.current_usage":"int. Redemptions counted so far.","data.per_user_limit":"int | null. Redemptions allowed per customer; null = unlimited.","data.user_usage":"int. ONLY when per_user_limit is set: this customer's invoices already using the code (0 when crm_user_id was not sent).","data.linked_products":"array. [{ id, name }] of the coupon's linked products (non-archived, max 100). [] = basket coupon. Show the names when the coupon is rejected for missing products.","data.linked_products[].id":"int. Linked product id (list_selling_products id).","data.linked_products[].name":"string. Linked product name.","data.start_date":"string (ISO datetime) | null. Valid from.","data.end_date":"string (ISO datetime) | null. Valid until.","data.is_active":"0 | 1. Always 1 when returned.","data.description":"string | null. NOT USED — dashboard label.","data.location_id":"int | null. NOT USED — legacy single-branch column (null on master coupons; branch links are not returned here).","data.customer_id":"int. NOT USED — tenant id.","data.created_at":"string (ISO datetime). NOT USED.","data.updated_at":"string (ISO datetime). NOT USED."},"master_rules":["Call list_selling_coupons first: the code must be in it (else reject without calling), and its product_ids decides the body — non-empty → product_ids (all active cart lines' product ids), empty → order_total (+ crm_user_id).","Always send location_id = the session branch (one id).","status:true is permission, not an amount: compute the discount from data.discount_type / data.discount_value exactly as the list_selling_coupons rules say, and put coupon_id / coupon_code / coupon_amount on the order (sync_invoice) — header for a basket coupon, the target_product_id item row for a product-scoped one.","Validation does not reserve the coupon: current_usage and per-customer usage change only when sync_invoice saves the order. Re-validate if the basket changes."],"caveats":"Code-derived (validate is a POST; not called live). Category links (category_ids) are ignored. min_order_value is ignored. The negative-total check only runs in basket mode.","example":{"status":true,"message":"Coupon is valid.","target_product_id":270965,"data":{"id":1001,"customer_id":437,"location_id":null,"code":"COLDBREW5","description":"5 off Cold Brew","discount_type":"value","discount_value":5,"min_order_value":null,"start_date":"2026-09-01T00:00:00.000Z","end_date":"2026-12-31T23:59:59.000Z","usage_limit":100,"per_user_limit":1,"is_active":1,"created_at":"2026-09-01T08:00:00.000Z","updated_at":"2026-09-01T08:00:00.000Z","current_usage":3,"linked_products":[{"id":270965,"name":"(1) Cold Brew"}],"user_usage":0}}},"parameters":{"type":"object","properties":{"coupon_code":{"type":"string","description":"Required."},"location_id":{"type":"number","description":"Required."},"order_total":{"type":"number","description":"Required when product_ids is not sent (basket coupon) — missing, non-numeric or negative → 400 \"Order total is required and cannot be negative.\"."},"crm_user_id":{"type":"number","description":"Optional."},"product_ids":{"type":"array","items":{"type":"number","description":"Product id of an active cart line (list_selling_products data.products[].id)."},"description":"Send ONLY for a product-scoped coupon (list_selling_coupons product_ids non-empty): the product_id of every active cart line."}},"required":["coupon_code","location_id"]},"module":"coupons","safety":"write"},{"name":"list_coupons","description":"GET /api/coupons. Why: Dashboard coupon list with branch, product and category links — ids for get_coupon / update_coupon / delete_coupon. Same handler and same response as list_selling_coupons. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_coupons\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/coupons","why":"Dashboard coupon list with branch, product and category links — ids for get_coupon / update_coupon / delete_coupon. Same handler and same response as list_selling_coupons.","response":{"description":"{ status, message, data[] }, not paginated. Error: HTTP 500 { status:false, message, data:null }.","fields":{"status":"boolean. true = success (also when empty).","message":"string. \"All coupons retrieved successfully.\"","data":"array. Coupons.","data[].id":"int. Coupon id (get_coupon / update_coupon / delete_coupon).","data[].code":"string. Coupon code (unique per tenant, case-insensitive).","data[].description":"string | null. Label.","data[].discount_type":"string. \"percentage\" or \"value\" (fixed amount).","data[].discount_value":"number | null. Percent or amount.","data[].min_order_value":"number | null. Stored minimum basket (not enforced at checkout).","data[].start_date":"string (ISO datetime) | null. Valid from.","data[].end_date":"string (ISO datetime) | null. Valid until (exact instant).","data[].usage_limit":"int | null. Total redemptions allowed; null = unlimited.","data[].per_user_limit":"int | null. Redemptions per CRM customer; null = unlimited.","data[].current_usage":"int. Redemptions counted so far.","data[].is_active":"0 | 1 | null. Only 1 validates at the POS.","data[].location_ids":"int[]. Linked branches. [] = none (unusable at the POS).","data[].location_id":"int | int[] | null. Linked branches: one id, an array, or null.","data[].product_ids":"int[]. Linked products (product-scoped coupon when non-empty).","data[].category_ids":"int[]. Linked categories (stored only; not enforced at checkout).","data[].customer_id":"int. Tenant id.","data[].created_at":"string (ISO datetime). Created.","data[].updated_at":"string (ISO datetime). Last updated."},"example":{"status":true,"message":"All coupons retrieved successfully.","data":[{"id":987,"customer_id":437,"location_id":null,"code":"MASTER50","description":"MASTER50","discount_type":"value","discount_value":10,"min_order_value":10,"start_date":"2026-04-01T00:00:00.000Z","end_date":"2026-04-30T00:00:00.000Z","usage_limit":10,"per_user_limit":2,"is_active":1,"created_at":"2026-04-01T06:56:40.000Z","updated_at":"2026-04-01T06:56:40.000Z","current_usage":0,"location_ids":[],"product_ids":[],"category_ids":[]}]}},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Optional."},"code":{"type":"string","description":"Optional."},"is_active":{"type":"string","enum":["0","1"],"description":"Optional."},"discount_type":{"type":"string","enum":["percentage","value"],"description":"Optional."},"min_order_value":{"type":"number","description":"Optional."},"start_date":{"type":"string","description":"Optional."},"end_date":{"type":"string","description":"Optional."}},"required":[]},"module":"coupons","safety":"read"},{"name":"get_coupon","description":"GET /api/coupons/:id. Why: Load one coupon with its branch, product and category links — read it before update_coupon (update replaces links). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_coupon\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/coupons/:id","why":"Load one coupon with its branch, product and category links — read it before update_coupon (update replaces links).","response":{"description":"{ status, message, data }. Not found: HTTP 404 { status:false, message:\"Coupon not found.\", data:null }. Non-numeric id: HTTP 400 \"Please enter valid coupon id\".","fields":{"status":"boolean. true = found.","message":"string. \"Coupon by id retrieved successfully.\"","data.id":"int. Coupon id.","data.code":"string. Coupon code.","data.description":"string | null. Label.","data.discount_type":"string. \"percentage\" or \"value\" (fixed amount).","data.discount_value":"number | null. Percent or amount.","data.min_order_value":"number | null. Stored minimum basket (not enforced at checkout).","data.start_date":"string (ISO datetime) | null. Valid from.","data.end_date":"string (ISO datetime) | null. Valid until (exact instant).","data.usage_limit":"int | null. Total redemptions allowed; null = unlimited.","data.per_user_limit":"int | null. Redemptions per CRM customer; null = unlimited.","data.current_usage":"int. Redemptions counted so far.","data.is_active":"0 | 1 | null. Only 1 validates at the POS.","data.location_ids":"int[]. Linked branches (legacy fallback: the row's own location_id). Send these back as location_id on update_coupon.","data.location_id":"int | int[] | null. Same branches: one id, an array, or null.","data.product_ids":"int[]. Linked products. Send back on update_coupon to keep them.","data.category_ids":"int[]. Linked categories. Send back on update_coupon to keep them.","data.customer_id":"int. Tenant id.","data.created_at":"string (ISO datetime). Created.","data.updated_at":"string (ISO datetime). Last updated."},"example":{"status":true,"message":"Coupon by id retrieved successfully.","data":{"id":987,"customer_id":437,"location_id":null,"code":"MASTER50","description":"MASTER50","discount_type":"value","discount_value":10,"min_order_value":10,"start_date":"2026-04-01T00:00:00.000Z","end_date":"2026-04-30T00:00:00.000Z","usage_limit":10,"per_user_limit":2,"is_active":1,"created_at":"2026-04-01T06:56:40.000Z","updated_at":"2026-04-01T06:56:40.000Z","current_usage":0,"product_ids":[],"category_ids":[],"location_ids":[]}}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Coupon id (list_coupons data[].id)."}},"required":["id"]},"module":"coupons","safety":"read"},{"name":"create_coupon","description":"POST /api/coupons. Why: Create a coupon linked to one or more branches, optionally limited to products (list_coupon_products_by_locations) or categories (list_coupon_categories_by_locations) — not both. Send is_active: 1, or the coupon is stored inactive and never validates. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_coupon\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/coupons","why":"Create a coupon linked to one or more branches, optionally limited to products (list_coupon_products_by_locations) or categories (list_coupon_categories_by_locations) — not both. Send is_active: 1, or the coupon is stored inactive and never validates.","response":{"description":"HTTP 201 { status:true, message, data }: data = the saved coupon row with location_id = the branch id (one) or ids (several). No product_ids/category_ids/location_ids in the response — call get_coupon. Errors: 400 no branch; 409 duplicate code; 400 \"Cannot link both products and categories...\" — returned AFTER the coupon and its branch links were already saved (the coupon exists without product/category links); 500 on a missing discount_type.","fields":{"status":"boolean. true = created.","message":"string. \"Your coupon has been successfully created with multiple locations.\"","data.id":"int. New coupon id.","data.location_id":"int | int[]. Branch id(s) you sent.","data.code":"string. Code.","data.description":"string | null. Label.","data.discount_type":"string. \"percentage\" or \"value\".","data.discount_value":"number | null. Percent or amount.","data.min_order_value":"number | null. Minimum basket.","data.start_date":"string (ISO datetime) | null. Valid from.","data.end_date":"string (ISO datetime) | null. Valid until.","data.usage_limit":"int | null. Total redemptions allowed.","data.per_user_limit":"int | null. Redemptions per customer.","data.current_usage":"int. 0 for a new coupon.","data.is_active":"0 | 1 | null. Active flag.","data.customer_id":"int. Tenant id.","data.created_at":"string (ISO datetime). Created.","data.updated_at":"string (ISO datetime). Same as created_at."},"example":{"status":true,"message":"Your coupon has been successfully created with multiple locations.","data":{"id":1001,"customer_id":437,"location_id":907,"code":"COLDBREW5","description":"5 off Cold Brew","discount_type":"value","discount_value":5,"min_order_value":null,"start_date":"2026-09-01T00:00:00.000Z","end_date":"2026-12-31T23:59:59.000Z","usage_limit":100,"per_user_limit":1,"is_active":1,"created_at":"2026-09-01T08:00:00.000Z","updated_at":"2026-09-01T08:00:00.000Z","current_usage":0}}},"parameters":{"type":"object","properties":{"code":{"type":"string","description":"Required."},"discount_type":{"type":"string","enum":["percentage","value"],"description":"Required (NOT NULL column; missing → 500)."},"location_id":{"type":"array","items":{"type":"number","description":"Branch id."},"description":"Required."},"discount_value":{"type":"number","description":"Optional (null = no discount)."},"is_active":{"type":"number","description":"1 = active, 0 = inactive."},"description":{"type":"string","description":"Optional."},"min_order_value":{"type":"number","description":"Optional."},"start_date":{"type":"string","description":"Optional."},"end_date":{"type":"string","description":"Optional."},"usage_limit":{"type":"number","description":"Optional."},"per_user_limit":{"type":"number","description":"Optional."},"product_ids":{"type":"array","items":{"type":"number","description":"Product id."},"description":"Optional."},"category_ids":{"type":"array","items":{"type":"number","description":"Category id."},"description":"Optional."}},"required":["code","discount_type","location_id"]},"module":"coupons","safety":"write"},{"name":"update_coupon","description":"PUT /api/coupons/:id. Why: Change a coupon. Coupon columns are PARTIAL (only the ones you send change), but branch links and product/category links are FULLY REPLACED: always send location_id, and send product_ids or category_ids again (from get_coupon) or they are removed. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_coupon\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/coupons/:id","why":"Change a coupon. Coupon columns are PARTIAL (only the ones you send change), but branch links and product/category links are FULLY REPLACED: always send location_id, and send product_ids or category_ids again (from get_coupon) or they are removed.","response":{"description":"HTTP 200 { status:true, message, data }: data = the full coupon row after the update with location_id = the branch id(s) you sent (no product/category links — call get_coupon). Send at least one coupon column besides location_id, or the server fails (500) before touching anything. Errors: 400 no branch; 409 duplicate code; 400 both products and categories (coupon columns and branch links already changed, product/category links kept); 500 unknown id.","fields":{"status":"boolean. true = updated.","message":"string. \"Your coupon has been successfully updated.\"","data.id":"int. Coupon id.","data.location_id":"int | int[]. Branch id(s) you sent.","data.code":"string. Code.","data.description":"string | null. Label.","data.discount_type":"string. \"percentage\" or \"value\".","data.discount_value":"number | null. Percent or amount.","data.min_order_value":"number | null. Minimum basket.","data.start_date":"string (ISO datetime) | null. Valid from.","data.end_date":"string (ISO datetime) | null. Valid until.","data.usage_limit":"int | null. Total redemptions allowed.","data.per_user_limit":"int | null. Redemptions per customer.","data.current_usage":"int. Redemptions counted so far (not reset by update).","data.is_active":"0 | 1 | null. Active flag.","data.customer_id":"int. Tenant id.","data.created_at":"string (ISO datetime). Created.","data.updated_at":"string (ISO datetime). Last updated."},"example":{"status":true,"message":"Your coupon has been successfully updated.","data":{"id":1001,"customer_id":437,"location_id":907,"code":"COLDBREW5","description":"5 off Cold Brew","discount_type":"value","discount_value":5,"min_order_value":null,"start_date":"2026-09-01T00:00:00.000Z","end_date":"2026-12-31T23:59:59.000Z","usage_limit":200,"per_user_limit":1,"is_active":1,"created_at":"2026-09-01T08:00:00.000Z","updated_at":"2026-09-10T08:00:00.000Z","current_usage":3}}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Coupon id (list_coupons data[].id)."},"location_id":{"type":"array","items":{"type":"number","description":"Branch id."},"description":"Required."},"code":{"type":"string","description":"Optional."},"description":{"type":"string","description":"Optional."},"discount_type":{"type":"string","enum":["percentage","value"],"description":"Optional."},"discount_value":{"type":"number","description":"Optional."},"min_order_value":{"type":"number","description":"Optional."},"start_date":{"type":"string","description":"Optional."},"end_date":{"type":"string","description":"Optional."},"usage_limit":{"type":"number","description":"Optional."},"per_user_limit":{"type":"number","description":"Optional."},"is_active":{"type":"number","description":"Optional."},"product_ids":{"type":"array","items":{"type":"number","description":"Product id."},"description":"Product links."},"category_ids":{"type":"array","items":{"type":"number","description":"Category id."},"description":"Category links (not enforced at checkout)."}},"required":["id","location_id"]},"module":"coupons","safety":"write"},{"name":"delete_coupon","description":"DELETE /api/coupons/:id. Why: Hard-delete a coupon with its branch and product/category links. Past invoices keep their coupon_code. To stop a coupon but keep history, prefer update_coupon is_active: 0. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_coupon\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/coupons/:id","why":"Hard-delete a coupon with its branch and product/category links. Past invoices keep their coupon_code. To stop a coupon but keep history, prefer update_coupon is_active: 0.","response":{"description":"HTTP 200 { status:true, message, data: id }. Unknown id: HTTP 200 { status:false, message:\"Coupon not found or already deleted.\", data:null }. Error: 500.","fields":{"status":"boolean. true = deleted; false = not found.","message":"string. \"Coupon has been successfully deleted.\"","data":"string. The deleted id as sent in the path (a string)."},"example":{"status":true,"message":"Coupon has been successfully deleted.","data":"1001"}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Coupon id (list_coupons data[].id)."}},"required":["id"]},"module":"coupons","safety":"destructive"},{"name":"list_coupon_products_by_locations","description":"GET /api/coupons/products-by-locations. Why: Product picker for create_coupon / update_coupon product_ids: live (not archived, not disabled) products linked to ANY of the given branches, sorted by name. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_coupon_products_by_locations\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/coupons/products-by-locations","why":"Product picker for create_coupon / update_coupon product_ids: live (not archived, not disabled) products linked to ANY of the given branches, sorted by name.","response":{"description":"{ success, data[], total, page, limit } (no loc → { data: [], total: 0 } only). Error: HTTP 500 { success:false, error, details }.","fields":{"success":"boolean. true = success.","data":"array. This page of products.","data[].id":"int. Product id → create_coupon / update_coupon product_ids.","data[].name":"string. Product (master) name.","total":"int. Matching products across all pages.","page":"int. Page returned.","limit":"int. Page size used."},"example":{"success":true,"data":[{"id":270965,"name":"(1) Cold Brew"},{"id":270946,"name":"(A) RADAR EV PATH REPL LENS CLEAR"},{"id":270966,"name":"*Ice Cola"}],"total":1075,"page":1,"limit":3}},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id(s), comma-separated — the coupon's branches."},"search":{"type":"string","description":"Optional."},"limit":{"type":"number","description":"Optional."},"page":{"type":"number","description":"Optional."}},"required":[]},"module":"coupons","safety":"read"},{"name":"list_coupon_categories_by_locations","description":"GET /api/coupons/categories-by-locations. Why: Category picker for create_coupon / update_coupon category_ids: active categories linked to ANY of the given branches, sorted by name. Note category links are not enforced at checkout. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_coupon_categories_by_locations\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/coupons/categories-by-locations","why":"Category picker for create_coupon / update_coupon category_ids: active categories linked to ANY of the given branches, sorted by name. Note category links are not enforced at checkout.","response":{"description":"{ success, data[], total, page, limit } (no loc → { data: [], total: 0 } only). Error: HTTP 500 { success:false, error, details }.","fields":{"success":"boolean. true = success.","data":"array. This page of categories.","data[].id":"int. Category id → create_coupon / update_coupon category_ids.","data[].name":"string. Category name.","total":"int. Matching categories across all pages.","page":"int. Page returned.","limit":"int. Page size used."},"example":{"success":true,"data":[{"id":15481,"name":"Alternate Options"},{"id":17556,"name":"Bag Accessory"},{"id":15230,"name":"Brewed Coffee"}],"total":24,"page":1,"limit":3}},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id(s), comma-separated — the coupon's branches."},"search":{"type":"string","description":"Optional."},"limit":{"type":"number","description":"Optional."},"page":{"type":"number","description":"Optional."}},"required":[]},"module":"coupons","safety":"read"},{"name":"list_discounts","description":"GET /api/discounts/web. Why: Every discount (discount code) of the tenant linked to the given branches — the dashboard Discounts page. These are the rows web-pos gets as get_user_extras data.discount_code[] (active and in date only) and offers in its discount dialog; they are NOT coupons (list_selling_coupons / validate_coupon). Use it to find a discount id for update_discount / delete_discount — also instead of get_discount, which is broken. Scope ids: products list_selling_products, categories list_selling_categories, channels list_selling_channels, payment methods list_selling_payment_methods, order types list_order_types, branches list_locations. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_discounts\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/discounts/web","why":"Every discount (discount code) of the tenant linked to the given branches — the dashboard Discounts page. These are the rows web-pos gets as get_user_extras data.discount_code[] (active and in date only) and offers in its discount dialog; they are NOT coupons (list_selling_coupons / validate_coupon). Use it to find a discount id for update_discount / delete_discount — also instead of get_discount, which is broken. Scope ids: products list_selling_products, categories list_selling_categories, channels list_selling_channels, payment methods list_selling_payment_methods, order types list_order_types, branches list_locations.","response":{"description":"200 → { status: true, message: \"All discounts retrieved successfully.\", data: row[] } — every discounts column (SELECT *) plus location_id (branch list), linked_location_id and location_name. Server filters: tenant = session and the loc branches, plus the optional category/day/time/complimentary filters. Inactive and expired discounts are included. 401 → { data: { error: \"Unauthorized access!\" } } for non-admin users. 500 → { data: { error: \"Internal server error: …\" } }.","fields":{"status":"boolean. true on success.","message":"string. \"All discounts retrieved successfully.\"","data":"array. One object per discount ([] = none).","data[].id":"int. Discount id → update_discount / delete_discount (id). Same id as get_user_extras data.discount_code[].id at the POS; send it as sync_invoice discount_id (order discount) or items[].discount_id (line discount) so the dashboard discount report names the discount (web-pos itself sends only the %).","data[].name":"string. Display name AND the code the cashier types at the POS (web-pos matches a typed code to name, trimmed, case-insensitive). Keep it unique per branch.","data[].discount_percent":"number | null. The value typed in the dashboard: a percent when discount_method = \"percentage\", a money amount when \"amount\". Legacy column — the POS reads discount_value.","data[].max_cap_amount":"number | null. Maximum money off. The POS re-derives the % so base × % ≤ cap (% = cap / base × 100). null / 0 = no cap. Dashboard column MAX DISCOUNT.","data[].min_basket_value":"number | null. Minimum order total (VAT included) for the code to be eligible at the POS; below it the code is listed as ineligible. null / 0 = none. web-pos checks it only when apply_discount_to is \"basket\", \"product\" or \"category\" — rows stored as \"entire_basket\" skip it. Dashboard column MIN BASKET.","data[].location_id":"int | int[]. Branches the discount is linked to (discounts_locations; legacy rows: discounts.location_id): a bare int when exactly one, else an array ([] = none). Always ALL the discount's branches, even when loc filters. Dashboard edit form pre-selects these branches.","data[].is_active":"0 | 1 | null. 1 = active. Only is_active = 1 rows reach the POS (get_user_extras). Dashboard Status (Active / Inactive).","data[].start_date":"string. ISO date-time from a DATE column, e.g. \"2026-03-01T00:00:00.000Z\"; the first 10 characters are the date. First valid day (inclusive).","data[].end_date":"string. Same format. Last valid day (inclusive). get_user_extras only returns codes whose start_date ≤ today ≤ end_date.","data[].discount_type":"0 | 1. STORED meaning: 1 = changeable (dashboard \"Is Changeable\" / list filter \"Changeable (POS)\"), 0 = fixed. It is NOT the percent/amount kind — that is discount_method. web-pos ignores it (applies discount_value as is).","data[].category_ids":"string | null. CSV of category ids (list_selling_categories / list_categories). \"\" / null = none. Non-empty → the POS applies the code only to cart lines of these categories.","data[].apply_discount_to":"string | null. \"entire_basket\" (dashboard Entire Basket; older rows \"basket\"), \"category\" or \"product\". POS scope: \"product\" → each line whose product_id is in product_ids (empty list = every line); otherwise non-empty category_ids → lines of those categories; otherwise the whole order.","data[].product_ids":"string | null. CSV of product ids (list_selling_products / list_products). Used when apply_discount_to = \"product\".","data[].start_time":"string | null. \"HH:MM:SS\" start of the daily window (POS compares hours:minutes, inclusive). null / \"\" with end_time empty = all day.","data[].end_time":"string | null. \"HH:MM:SS\" end of the daily window. end < start = window wraps midnight.","data[].is_available_on":"string | null. CSV of lowercase full weekday names (\"monday,…,sunday\"). \"\" / null = every day.","data[].is_auto_apply":"0 | 1 | null. Dashboard \"Auto Apply\" switch (list column AUTO APPLY Yes/No). web-pos only shows it as a label — it never applies a code automatically.","data[].is_promotion":"0 | 1 | null. Promotion flag. No dashboard control (new discounts save 0, edits keep the stored value); when 1 the dashboard form requires payment methods instead of category/product ids. Not used by web-pos.","data[].payment_methods":"string | null. CSV of payment-method ids (list_selling_payment_methods / list_payment_methods [].id). No dashboard control (kept on edit). web-pos shows the names in the code details but does NOT enforce it.","data[].channel_ids":"string | null. CSV of channel ids (list_selling_channels data[].channel_id / list_channels). Dashboard \"Channels\". The POS enforces it only when the POS user setting is_channels_enable = 1 and the list is non-empty (code must list the current channel).","data[].customer_id":"int. Tenant (merchant) id. Always your own tenant; not needed.","data[].order_type_ids":"string | null. CSV of order-type ids (list_order_types / list_selling_order_types). Dashboard \"Order Types\". Stored only — web-pos does not check it.","data[].discount_method":"string | null. \"percentage\" | \"amount\" (legacy rows: \"changeable\"). Written by the server from the body discount_type (0 → \"amount\", 1/2 → \"percentage\", omitted → null). POS: \"amount\" = fixed money, anything else = percent.","data[].discount_value":"number | null. Copy of discount_percent written by the server. THE value the POS uses: percent (capped at 100) or money amount per discount_method.","data[].linked_location_id":"int | null. One arbitrary linked branch (join artefact). Not needed — use location_id.","data[].location_name":"string. Name of the filtered branch when exactly one branch was filtered (loc = one id, or loc omitted → the user's branch), else \"\". Same on every row; not the discount's own branch list."},"example":{"status":true,"message":"All discounts retrieved successfully.","data":[{"id":2376,"name":"Method Test","discount_percent":50,"max_cap_amount":100,"min_basket_value":20,"location_id":[907,908,909,922],"is_active":1,"start_date":"2026-03-01T00:00:00.000Z","end_date":"2026-03-31T00:00:00.000Z","discount_type":0,"category_ids":"15481,15230","apply_discount_to":"category","product_ids":null,"start_time":"00:00:00","end_time":"23:59:00","is_available_on":"monday,tuesday,wednesday,thursday,friday,saturday,sunday","is_auto_apply":1,"is_promotion":0,"payment_methods":null,"channel_ids":"463,509,482,515","customer_id":437,"order_type_ids":"1139,1174,1129,1127","discount_method":"percentage","discount_value":50,"linked_location_id":907,"location_name":"Default location"}]},"master_rules":["The dashboard Discounts page calls this with loc (none on first open = the user's branch; the Location filter sends a csv of ticked branches or \"All\") plus _page/_limit that the server ignores. Search by name, Status (is_active), Type (Discount % = discount_method \"percentage\", Discount Amount = \"amount\", Changeable (POS) = discount_type 1 or discount_method \"changeable\") and paging are all done client-side on this list — filter the same way yourself.","Each row the dashboard shows: NAME = name; TYPE = \"Discount %\" / \"Discount Amount\" from discount_method; VALUE = discount_percent + \"%\" or \" AED\" (legacy changeable rows without a value show \"POS Entry\"); MAX DISCOUNT = max_cap_amount; MIN BASKET = min_basket_value; STATUS = is_active; APPLY TO = apply_discount_to (basket/entire_basket = Entire Basket); DURATION = start_date – end_date, start_time – end_time (12 h) and the day list (\"All days\" for seven); AUTO APPLY = is_auto_apply Yes/No; edit / delete actions.","Editing opens the form from THIS row (there is no get call): location_id → Locations, category_ids / product_ids / channel_ids / order_type_ids / payment_methods csv → chips, apply_discount_to is re-derived (product_ids non-empty → Product, else category_ids → Category, else Entire Basket), dates cut to YYYY-MM-DD, is_changeable = discount_type 1 or discount_method \"changeable\". Build update_discount the same way.","What the POS gets: get_user_extras data.discount_code[] = the rows of THIS branch with is_active = 1 and start_date ≤ today ≤ end_date; web-pos offers them only when the POS user setting discount_codes = 1 (list_pos_users). It then checks per code, client-side: end/start date, weekday (is_available_on), time window, min_basket_value, and channel_ids when is_channels_enable = 1. It ignores discount_type (changeable), is_auto_apply, is_promotion, order_type_ids and payment_methods.","How the POS applies a code: value = discount_value; discount_method \"amount\" → % = amount / base × 100 (max 100), else % = discount_value (max 100); max_cap_amount then lowers the % to cap / base × 100. Whole-order code (no product/category scope) → the ORDER discount %: sync_invoice discountpct, discountamount = (lines total + charges) × % / 100, payable = total − discountamount, and VAT is extracted from that discounted payable (vat = payable − payable / (1 + VAT%/100)). Product/category code → each matching line's discount_pct (line price reduced before its VAT is extracted). web-pos sends no discount_id — send sync_invoice discount_id (order) / items[].discount_id + discount_name (lines) = this id / name so reports attribute the discount.","Coupons are separate (list_selling_coupons / validate_coupon, stored in coupon_id / coupon_code / coupon_amount). A basket coupon REPLACES the order discount (and so an order-level discount code); a product coupon adds to one line's discount_pct. The manual order/line discount a cashier types is capped by max_discount_permit (get_user_extras), codes are not.","Master tenant: a discount belongs to the branches in location_id; to see every discount send loc=\"All\" (or the key's branches). location_name is the filtered branch's name, not per discount."]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id or comma-separated ids (list_locations); keeps discounts linked to any of them."},"category_ids":{"type":"number","description":"ONE category id: only discounts whose category_ids contain it (a csv of several ids matches nothing)."},"is_available_on":{"type":"string","description":"ONE lowercase weekday name (e.g."},"start_time":{"type":"string","description":"\"HH:MM:SS\"."},"end_time":{"type":"string","description":"\"HH:MM:SS\"."},"complimentary":{"type":"string","description":"Any non-empty value (even \"0\") → only discounts with discount_percent = 100."}},"required":[]},"module":"discounts","safety":"read"},{"name":"get_discount","description":"GET /api/discounts/:id. Why: Meant to load one discount by id, but the server handler is broken: it reads the id from a route param that does not exist and ALWAYS answers 400 \"Please enter valid discount id\". Use list_discounts (loc=\"All\") and pick the row by id instead — that is also what the dashboard does before an edit. BROKEN on the server: always returns 400 \"Please enter valid discount id\" — use list_discounts (loc=\"All\") and pick the row by id. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_discount\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/discounts/:id","why":"Meant to load one discount by id, but the server handler is broken: it reads the id from a route param that does not exist and ALWAYS answers 400 \"Please enter valid discount id\". Use list_discounts (loc=\"All\") and pick the row by id instead — that is also what the dashboard does before an edit.","response":{"description":"Live (dev1): 400 → { status: false, message: \"Please enter valid discount id\", data: null } for every id — the route is /:id but the handler reads req.params.discount_id. If fixed it would return 200 { status: true, message: \"Discount by id retrieved successfully.\", data: row } (raw discounts row, no tenant check, location_id null on master) or 404 { status: false, message: \"Discount not found.\", data: null }.","fields":{"status":"boolean. false (always, today).","message":"string. \"Please enter valid discount id\" (today); \"Discount by id retrieved successfully.\" / \"Discount not found.\" once fixed.","data":"object | null. null on error; the raw discounts row once fixed (fields below).","data.id":"int. Discount id → update_discount / delete_discount (id). Same id as get_user_extras data.discount_code[].id at the POS; send it as sync_invoice discount_id (order discount) or items[].discount_id (line discount) so the dashboard discount report names the discount (web-pos itself sends only the %).","data.name":"string. Display name AND the code the cashier types at the POS (web-pos matches a typed code to name, trimmed, case-insensitive). Keep it unique per branch.","data.discount_percent":"number | null. The value typed in the dashboard: a percent when discount_method = \"percentage\", a money amount when \"amount\". Legacy column — the POS reads discount_value.","data.max_cap_amount":"number | null. Maximum money off. The POS re-derives the % so base × % ≤ cap (% = cap / base × 100). null / 0 = no cap. Dashboard column MAX DISCOUNT.","data.min_basket_value":"number | null. Minimum order total (VAT included) for the code to be eligible at the POS; below it the code is listed as ineligible. null / 0 = none. web-pos checks it only when apply_discount_to is \"basket\", \"product\" or \"category\" — rows stored as \"entire_basket\" skip it. Dashboard column MIN BASKET.","data.location_id":"int | null. Legacy single-branch column: null on master tenants (branches are the discounts_locations links, visible only through list_discounts → location_id).","data.is_active":"0 | 1 | null. 1 = active. Only is_active = 1 rows reach the POS (get_user_extras). Dashboard Status (Active / Inactive).","data.start_date":"string. ISO date-time from a DATE column, e.g. \"2026-03-01T00:00:00.000Z\"; the first 10 characters are the date. First valid day (inclusive).","data.end_date":"string. Same format. Last valid day (inclusive). get_user_extras only returns codes whose start_date ≤ today ≤ end_date.","data.discount_type":"0 | 1. STORED meaning: 1 = changeable (dashboard \"Is Changeable\" / list filter \"Changeable (POS)\"), 0 = fixed. It is NOT the percent/amount kind — that is discount_method. web-pos ignores it (applies discount_value as is).","data.category_ids":"string | null. CSV of category ids (list_selling_categories / list_categories). \"\" / null = none. Non-empty → the POS applies the code only to cart lines of these categories.","data.apply_discount_to":"string | null. \"entire_basket\" (dashboard Entire Basket; older rows \"basket\"), \"category\" or \"product\". POS scope: \"product\" → each line whose product_id is in product_ids (empty list = every line); otherwise non-empty category_ids → lines of those categories; otherwise the whole order.","data.product_ids":"string | null. CSV of product ids (list_selling_products / list_products). Used when apply_discount_to = \"product\".","data.start_time":"string | null. \"HH:MM:SS\" start of the daily window (POS compares hours:minutes, inclusive). null / \"\" with end_time empty = all day.","data.end_time":"string | null. \"HH:MM:SS\" end of the daily window. end < start = window wraps midnight.","data.is_available_on":"string | null. CSV of lowercase full weekday names (\"monday,…,sunday\"). \"\" / null = every day.","data.is_auto_apply":"0 | 1 | null. Dashboard \"Auto Apply\" switch (list column AUTO APPLY Yes/No). web-pos only shows it as a label — it never applies a code automatically.","data.is_promotion":"0 | 1 | null. Promotion flag. No dashboard control (new discounts save 0, edits keep the stored value); when 1 the dashboard form requires payment methods instead of category/product ids. Not used by web-pos.","data.payment_methods":"string | null. CSV of payment-method ids (list_selling_payment_methods / list_payment_methods [].id). No dashboard control (kept on edit). web-pos shows the names in the code details but does NOT enforce it.","data.channel_ids":"string | null. CSV of channel ids (list_selling_channels data[].channel_id / list_channels). Dashboard \"Channels\". The POS enforces it only when the POS user setting is_channels_enable = 1 and the list is non-empty (code must list the current channel).","data.customer_id":"int. Tenant (merchant) id. Always your own tenant; not needed.","data.order_type_ids":"string | null. CSV of order-type ids (list_order_types / list_selling_order_types). Dashboard \"Order Types\". Stored only — web-pos does not check it.","data.discount_method":"string | null. \"percentage\" | \"amount\" (legacy rows: \"changeable\"). Written by the server from the body discount_type (0 → \"amount\", 1/2 → \"percentage\", omitted → null). POS: \"amount\" = fixed money, anything else = percent.","data.discount_value":"number | null. Copy of discount_percent written by the server. THE value the POS uses: percent (capped at 100) or money amount per discount_method."},"example":{"status":false,"message":"Please enter valid discount id","data":null},"master_rules":["Do not rely on this tool: it answers 400 for every id. Get the discount from list_discounts (loc=\"All\") by id — the dashboard never calls it either; its edit form is filled from the list row.","Even when fixed, data.location_id is null on master tenants: the branch list is only in list_discounts → location_id."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Discount id (list_discounts data[].id)."}},"required":["id"]},"module":"discounts","safety":"read"},{"name":"create_discount","description":"POST /api/discounts. Why: Add a discount (discount code) that web-pos offers in its discount dialog (get_user_extras data.discount_code[]) at the listed branches while active and in date. Not a coupon (create_coupon). Branch ids from list_locations; scope ids: products list_selling_products, categories list_selling_categories, channels list_selling_channels (channel_id), order types list_order_types, payment methods list_selling_payment_methods. Check list_discounts first so the name (= the code cashiers type) is not a duplicate. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_discount\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/discounts","why":"Add a discount (discount code) that web-pos offers in its discount dialog (get_user_extras data.discount_code[]) at the listed branches while active and in date. Not a coupon (create_coupon). Branch ids from list_locations; scope ids: products list_selling_products, categories list_selling_categories, channels list_selling_channels (channel_id), order types list_order_types, payment methods list_selling_payment_methods. Check list_discounts first so the name (= the code cashiers type) is not a duplicate.","response":{"description":"201 → { status: true, message: \"Your discount has been successfully created.\", data: row } (row read back; location_id null on master — the branches are the discounts_locations links). 400 → { status: false, message: \"At least one Location ID is required.\", data: null }. 500 → { status: false, message: \"An error occurred while creating the discount.\", data: null } (e.g. name / start_date / end_date missing or an invalid date/time) or \"Failed to create discount. Please try again.\". Not transactional: a failure while writing the branch links leaves the discount saved with no branch (fix with update_discount). Unknown body keys are ignored.","fields":{"status":"boolean. true on success; false on every error.","message":"string. Human-readable result or reason.","data":"object | null. The saved row read back (fields below); null on error.","data.id":"int. Discount id → update_discount / delete_discount (id). Same id as get_user_extras data.discount_code[].id at the POS; send it as sync_invoice discount_id (order discount) or items[].discount_id (line discount) so the dashboard discount report names the discount (web-pos itself sends only the %).","data.name":"string. Display name AND the code the cashier types at the POS (web-pos matches a typed code to name, trimmed, case-insensitive). Keep it unique per branch.","data.discount_percent":"number | null. The value typed in the dashboard: a percent when discount_method = \"percentage\", a money amount when \"amount\". Legacy column — the POS reads discount_value.","data.max_cap_amount":"number | null. Maximum money off. The POS re-derives the % so base × % ≤ cap (% = cap / base × 100). null / 0 = no cap. Dashboard column MAX DISCOUNT.","data.min_basket_value":"number | null. Minimum order total (VAT included) for the code to be eligible at the POS; below it the code is listed as ineligible. null / 0 = none. web-pos checks it only when apply_discount_to is \"basket\", \"product\" or \"category\" — rows stored as \"entire_basket\" skip it. Dashboard column MIN BASKET.","data.location_id":"int | null. Legacy single-branch column: null on master tenants (branches are the discounts_locations links, visible only through list_discounts → location_id).","data.is_active":"0 | 1 | null. 1 = active. Only is_active = 1 rows reach the POS (get_user_extras). Dashboard Status (Active / Inactive).","data.start_date":"string. ISO date-time from a DATE column, e.g. \"2026-03-01T00:00:00.000Z\"; the first 10 characters are the date. First valid day (inclusive).","data.end_date":"string. Same format. Last valid day (inclusive). get_user_extras only returns codes whose start_date ≤ today ≤ end_date.","data.discount_type":"0 | 1. STORED meaning: 1 = changeable (dashboard \"Is Changeable\" / list filter \"Changeable (POS)\"), 0 = fixed. It is NOT the percent/amount kind — that is discount_method. web-pos ignores it (applies discount_value as is).","data.category_ids":"string | null. CSV of category ids (list_selling_categories / list_categories). \"\" / null = none. Non-empty → the POS applies the code only to cart lines of these categories.","data.apply_discount_to":"string | null. \"entire_basket\" (dashboard Entire Basket; older rows \"basket\"), \"category\" or \"product\". POS scope: \"product\" → each line whose product_id is in product_ids (empty list = every line); otherwise non-empty category_ids → lines of those categories; otherwise the whole order.","data.product_ids":"string | null. CSV of product ids (list_selling_products / list_products). Used when apply_discount_to = \"product\".","data.start_time":"string | null. \"HH:MM:SS\" start of the daily window (POS compares hours:minutes, inclusive). null / \"\" with end_time empty = all day.","data.end_time":"string | null. \"HH:MM:SS\" end of the daily window. end < start = window wraps midnight.","data.is_available_on":"string | null. CSV of lowercase full weekday names (\"monday,…,sunday\"). \"\" / null = every day.","data.is_auto_apply":"0 | 1 | null. Dashboard \"Auto Apply\" switch (list column AUTO APPLY Yes/No). web-pos only shows it as a label — it never applies a code automatically.","data.is_promotion":"0 | 1 | null. Promotion flag. No dashboard control (new discounts save 0, edits keep the stored value); when 1 the dashboard form requires payment methods instead of category/product ids. Not used by web-pos.","data.payment_methods":"string | null. CSV of payment-method ids (list_selling_payment_methods / list_payment_methods [].id). No dashboard control (kept on edit). web-pos shows the names in the code details but does NOT enforce it.","data.channel_ids":"string | null. CSV of channel ids (list_selling_channels data[].channel_id / list_channels). Dashboard \"Channels\". The POS enforces it only when the POS user setting is_channels_enable = 1 and the list is non-empty (code must list the current channel).","data.customer_id":"int. Tenant (merchant) id. Always your own tenant; not needed.","data.order_type_ids":"string | null. CSV of order-type ids (list_order_types / list_selling_order_types). Dashboard \"Order Types\". Stored only — web-pos does not check it.","data.discount_method":"string | null. \"percentage\" | \"amount\" (legacy rows: \"changeable\"). Written by the server from the body discount_type (0 → \"amount\", 1/2 → \"percentage\", omitted → null). POS: \"amount\" = fixed money, anything else = percent.","data.discount_value":"number | null. Copy of discount_percent written by the server. THE value the POS uses: percent (capped at 100) or money amount per discount_method."},"example_request":{"name":"Weekend 10%","discount_type":1,"discount_percent":10,"is_changeable":0,"max_cap_amount":50,"min_basket_value":30,"is_active":1,"location_id":[907],"start_date":"2026-10-01","end_date":"2026-10-31","start_time":"00:00:00","end_time":"23:59:00","is_available_on":"friday,saturday,sunday","apply_discount_to":"entire_basket","category_ids":[],"product_ids":[],"channel_ids":[],"order_type_ids":[],"payment_methods":[],"is_auto_apply":0,"is_promotion":0},"example":{"status":true,"message":"Your discount has been successfully created.","data":{"id":2400,"name":"Weekend 10%","discount_percent":10,"max_cap_amount":50,"min_basket_value":30,"location_id":null,"is_active":1,"start_date":"2026-10-01T00:00:00.000Z","end_date":"2026-10-31T00:00:00.000Z","discount_type":0,"category_ids":null,"apply_discount_to":"entire_basket","product_ids":null,"start_time":"00:00:00","end_time":"23:59:00","is_available_on":"friday,saturday,sunday","is_auto_apply":0,"is_promotion":0,"payment_methods":null,"channel_ids":null,"customer_id":437,"order_type_ids":null,"discount_method":"percentage","discount_value":10}},"master_rules":["Validate like the dashboard form before calling: name required; discount_type 1 (percent) or 0 (amount); discount_percent required (percent 0–100, amount ≥ 0); is_active; ≥ 1 branch in location_id; start_date and end_date required; start_time and end_time required; apply_discount_to \"category\" needs ≥ 1 category_ids and \"product\" needs ≥ 1 product_ids (unless is_promotion); is_promotion = 1 needs ≥ 1 payment_methods.","The dashboard sends: name, discount_type (default 1), discount_percent, is_changeable 0/1, max_cap_amount and min_basket_value (null when empty), is_active (default 1), location_id (csv of ticked branches), start_date / end_date \"YYYY-MM-DD\", start_time / end_time \"HH:MM:SS\" (defaults 00:00:00 / 23:59:00; equal times → end − 1 minute), is_available_on (full weekday names, default all seven), apply_discount_to (\"basket\" default, \"category\", \"product\"), category_ids / product_ids csv only for their scope (else \"\"), channel_ids, order_type_ids, payment_methods csv, is_auto_apply 0/1, is_promotion 0. Send the same (arrays are fine).","Always send is_active: 1 for a discount the POS should offer — omitted is stored as NULL and get_user_extras only returns is_active = 1.","At the POS the code is found by name (typed) or from the list, applied as a % (discount_value; amount → % of the order/line total, capped at 100 and by max_cap_amount). Whole-order codes set the order discountpct/discountamount (VAT extracted from the discounted payable); product/category codes set line discount_pct. is_auto_apply, is_changeable, is_promotion, order_type_ids and payment_methods are not enforced by web-pos.","On success the dashboard just closes the form and refetches the list: confirm with list_discounts (loc = one of the branches) — the response row has no branch list."]},"parameters":{"type":"object","properties":{"location_id":{"type":"array","items":{"type":"number","description":"Branch id (list_locations)."},"description":"Branches the discount is valid at (list_locations ids)."},"name":{"type":"string","description":"Display name, also the code the cashier types at the POS."},"discount_type":{"type":"number","description":"Kind of value: 1 (or 2) = percentage, 0 = fixed amount."},"discount_percent":{"type":"number","description":"The value: percent (0–100) when discount_type = 1, money amount (≥ 0) when 0."},"is_changeable":{"type":"string","description":"1/true = changeable (stored as discount_type = 1), 0/false/omitted = fixed (discount_type = 0)."},"max_cap_amount":{"type":"number","description":"Maximum money off (percentage discounts)."},"min_basket_value":{"type":"number","description":"Minimum order total for the code to be eligible at the POS."},"is_active":{"type":"string","description":"1 = active, 0 = inactive (Number(value); \"true\" as a string → 0)."},"start_date":{"type":"string","description":"First valid day, \"YYYY-MM-DD\"."},"end_date":{"type":"string","description":"Last valid day (inclusive), \"YYYY-MM-DD\"."},"start_time":{"type":"string","description":"Daily window start \"HH:MM\" or \"HH:MM:SS\" (HH:MM → HH:MM:00)."},"end_time":{"type":"string","description":"Daily window end, same format; earlier than start_time = wraps midnight."},"is_available_on":{"type":"string","description":"CSV of lowercase full weekday names (\"monday,tuesday,…,sunday\"); \"\" → null = every day."},"apply_discount_to":{"type":"string","enum":["entire_basket","basket","category","product"],"description":"Scope: \"entire_basket\" (whole order; \"basket\" / \"entire basket\" are stored as \"entire_basket\"), \"category\" (send category_ids), \"product\" (send product_ids; \"i…"},"category_ids":{"type":"array","items":{"type":"number","description":"Category id (list_selling_categories / list_categories)."},"description":"Categories for apply_discount_to = \"category\" (csv string also accepted)."},"product_ids":{"type":"array","items":{"type":"number","description":"Product id (list_selling_products / list_products)."},"description":"Products for apply_discount_to = \"product\" (csv string also accepted)."},"channel_ids":{"type":"array","items":{"type":"number","description":"Channel id (list_selling_channels channel_id)."},"description":"Sales channels the code is valid on (csv also accepted)."},"order_type_ids":{"type":"array","items":{"type":"number","description":"Order type id (list_order_types)."},"description":"Order types (csv also accepted)."},"payment_methods":{"type":"array","items":{"type":"number","description":"Payment method id (list_selling_payment_methods [].id)."},"description":"Payment methods (csv also accepted)."},"is_auto_apply":{"type":"string","description":"1/true = auto apply, 0/false = cashier picks it."},"is_promotion":{"type":"string","description":"1/true = promotion."}},"required":["location_id","name","start_date","end_date"]},"module":"discounts","safety":"write"},{"name":"update_discount","description":"PUT /api/discounts/:id. Why: Change a discount (discount code): value, scope, branches, dates, active flag. NOT a patch — read the row from list_discounts (loc=\"All\"; get_discount is broken) and send every field again, or the omitted scope/value/time fields are cleared. The change reaches web-pos with its next get_user_extras. Scope ids: list_selling_products, list_selling_categories, list_selling_channels, list_selling_payment_methods, list_order_types; branches list_locations. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_discount\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/discounts/:id","why":"Change a discount (discount code): value, scope, branches, dates, active flag. NOT a patch — read the row from list_discounts (loc=\"All\"; get_discount is broken) and send every field again, or the omitted scope/value/time fields are cleared. The change reaches web-pos with its next get_user_extras. Scope ids: list_selling_products, list_selling_categories, list_selling_channels, list_selling_payment_methods, list_order_types; branches list_locations.","response":{"description":"200 → { status: true, message: \"Your discount has been successfully updated.\", data: row } (row read back before the branch links are rewritten; location_id null on master). 400 → { status: false, message: \"Please enter a valid discount ID.\" | \"At least one Location ID is required.\", data: null }. 500 → { status: false, message: \"An error occurred while updating the discount.\", data: null } (unknown id, invalid date/time). Only discounts columns are written (other keys ignored); branch links are deleted and re-inserted after the row update (not transactional).","fields":{"status":"boolean. true on success; false on every error.","message":"string. Human-readable result or reason.","data":"object | null. The row read back after the update (fields below); null on error.","data.id":"int. Discount id → update_discount / delete_discount (id). Same id as get_user_extras data.discount_code[].id at the POS; send it as sync_invoice discount_id (order discount) or items[].discount_id (line discount) so the dashboard discount report names the discount (web-pos itself sends only the %).","data.name":"string. Display name AND the code the cashier types at the POS (web-pos matches a typed code to name, trimmed, case-insensitive). Keep it unique per branch.","data.discount_percent":"number | null. The value typed in the dashboard: a percent when discount_method = \"percentage\", a money amount when \"amount\". Legacy column — the POS reads discount_value.","data.max_cap_amount":"number | null. Maximum money off. The POS re-derives the % so base × % ≤ cap (% = cap / base × 100). null / 0 = no cap. Dashboard column MAX DISCOUNT.","data.min_basket_value":"number | null. Minimum order total (VAT included) for the code to be eligible at the POS; below it the code is listed as ineligible. null / 0 = none. web-pos checks it only when apply_discount_to is \"basket\", \"product\" or \"category\" — rows stored as \"entire_basket\" skip it. Dashboard column MIN BASKET.","data.location_id":"int | null. Legacy single-branch column: null on master tenants (branches are the discounts_locations links, visible only through list_discounts → location_id).","data.is_active":"0 | 1 | null. 1 = active. Only is_active = 1 rows reach the POS (get_user_extras). Dashboard Status (Active / Inactive).","data.start_date":"string. ISO date-time from a DATE column, e.g. \"2026-03-01T00:00:00.000Z\"; the first 10 characters are the date. First valid day (inclusive).","data.end_date":"string. Same format. Last valid day (inclusive). get_user_extras only returns codes whose start_date ≤ today ≤ end_date.","data.discount_type":"0 | 1. STORED meaning: 1 = changeable (dashboard \"Is Changeable\" / list filter \"Changeable (POS)\"), 0 = fixed. It is NOT the percent/amount kind — that is discount_method. web-pos ignores it (applies discount_value as is).","data.category_ids":"string | null. CSV of category ids (list_selling_categories / list_categories). \"\" / null = none. Non-empty → the POS applies the code only to cart lines of these categories.","data.apply_discount_to":"string | null. \"entire_basket\" (dashboard Entire Basket; older rows \"basket\"), \"category\" or \"product\". POS scope: \"product\" → each line whose product_id is in product_ids (empty list = every line); otherwise non-empty category_ids → lines of those categories; otherwise the whole order.","data.product_ids":"string | null. CSV of product ids (list_selling_products / list_products). Used when apply_discount_to = \"product\".","data.start_time":"string | null. \"HH:MM:SS\" start of the daily window (POS compares hours:minutes, inclusive). null / \"\" with end_time empty = all day.","data.end_time":"string | null. \"HH:MM:SS\" end of the daily window. end < start = window wraps midnight.","data.is_available_on":"string | null. CSV of lowercase full weekday names (\"monday,…,sunday\"). \"\" / null = every day.","data.is_auto_apply":"0 | 1 | null. Dashboard \"Auto Apply\" switch (list column AUTO APPLY Yes/No). web-pos only shows it as a label — it never applies a code automatically.","data.is_promotion":"0 | 1 | null. Promotion flag. No dashboard control (new discounts save 0, edits keep the stored value); when 1 the dashboard form requires payment methods instead of category/product ids. Not used by web-pos.","data.payment_methods":"string | null. CSV of payment-method ids (list_selling_payment_methods / list_payment_methods [].id). No dashboard control (kept on edit). web-pos shows the names in the code details but does NOT enforce it.","data.channel_ids":"string | null. CSV of channel ids (list_selling_channels data[].channel_id / list_channels). Dashboard \"Channels\". The POS enforces it only when the POS user setting is_channels_enable = 1 and the list is non-empty (code must list the current channel).","data.customer_id":"int. Tenant (merchant) id. Always your own tenant; not needed.","data.order_type_ids":"string | null. CSV of order-type ids (list_order_types / list_selling_order_types). Dashboard \"Order Types\". Stored only — web-pos does not check it.","data.discount_method":"string | null. \"percentage\" | \"amount\" (legacy rows: \"changeable\"). Written by the server from the body discount_type (0 → \"amount\", 1/2 → \"percentage\", omitted → null). POS: \"amount\" = fixed money, anything else = percent.","data.discount_value":"number | null. Copy of discount_percent written by the server. THE value the POS uses: percent (capped at 100) or money amount per discount_method."},"example_request":{"name":"Weekend 15%","discount_type":1,"discount_percent":15,"is_changeable":0,"max_cap_amount":50,"min_basket_value":30,"is_active":1,"location_id":[907,908],"start_date":"2026-10-01","end_date":"2026-10-31","start_time":"00:00:00","end_time":"23:59:00","is_available_on":"friday,saturday,sunday","apply_discount_to":"entire_basket","category_ids":[],"product_ids":[],"channel_ids":[],"order_type_ids":[],"payment_methods":[],"is_auto_apply":0,"is_promotion":0},"example":{"status":true,"message":"Your discount has been successfully updated.","data":{"id":2400,"name":"Weekend 15%","discount_percent":15,"max_cap_amount":50,"min_basket_value":30,"location_id":null,"is_active":1,"start_date":"2026-10-01T00:00:00.000Z","end_date":"2026-10-31T00:00:00.000Z","discount_type":0,"category_ids":null,"apply_discount_to":"entire_basket","product_ids":null,"start_time":"00:00:00","end_time":"23:59:00","is_available_on":"friday,saturday,sunday","is_auto_apply":0,"is_promotion":0,"payment_methods":null,"channel_ids":null,"customer_id":437,"order_type_ids":null,"discount_method":"percentage","discount_value":15}},"master_rules":["Not a patch. Omitted → CLEARED (null): discount_percent (and discount_value), discount_type (→ discount_method null), max_cap_amount, min_basket_value, start_time, end_time, category_ids, product_ids, channel_ids, order_type_ids, payment_methods; is_changeable omitted → 0. Omitted → unchanged: name, is_active, start_date, end_date, is_available_on, apply_discount_to, is_auto_apply, is_promotion. location_id is required and REPLACES all branches.","Do what the dashboard edit does: start from the list_discounts row, map discount_method back to discount_type (\"amount\" → 0, else 1), is_changeable = discount_type 1 or discount_method \"changeable\", dates \"YYYY-MM-DD\" (first 10 chars), csv id columns → arrays, location_id = the row's branches (add/remove as asked), then resend everything with the changes.","When the MCP key is scoped to some branches, location_id is clamped to them — the update then REMOVES the discount from every other branch it had. Check list_discounts location_id first.","On success the dashboard closes the form and refetches list_discounts; the POS picks the change up on its next get_user_extras (orders already saved keep their discount)."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Discount id (list_discounts data[].id)."},"location_id":{"type":"array","items":{"type":"number","description":"Branch id (list_locations)."},"description":"Branches the discount is valid at (list_locations ids)."},"name":{"type":"string","description":"Display name, also the code the cashier types at the POS."},"discount_type":{"type":"number","description":"Kind of value: 1 (or 2) = percentage, 0 = fixed amount."},"discount_percent":{"type":"number","description":"The value: percent (0–100) when discount_type = 1, money amount (≥ 0) when 0."},"is_changeable":{"type":"string","description":"1/true = changeable (stored as discount_type = 1), 0/false/omitted = fixed (discount_type = 0)."},"max_cap_amount":{"type":"number","description":"Maximum money off (percentage discounts)."},"min_basket_value":{"type":"number","description":"Minimum order total for the code to be eligible at the POS."},"is_active":{"type":"string","description":"1 = active, 0 = inactive (Number(value); \"true\" as a string → 0)."},"start_date":{"type":"string","description":"First valid day, \"YYYY-MM-DD\"."},"end_date":{"type":"string","description":"Last valid day (inclusive), \"YYYY-MM-DD\"."},"start_time":{"type":"string","description":"Daily window start \"HH:MM\" or \"HH:MM:SS\" (HH:MM → HH:MM:00)."},"end_time":{"type":"string","description":"Daily window end, same format; earlier than start_time = wraps midnight."},"is_available_on":{"type":"string","description":"CSV of lowercase full weekday names (\"monday,tuesday,…,sunday\"); \"\" → null = every day."},"apply_discount_to":{"type":"string","enum":["entire_basket","basket","category","product"],"description":"Scope: \"entire_basket\" (whole order; \"basket\" / \"entire basket\" are stored as \"entire_basket\"), \"category\" (send category_ids), \"product\" (send product_ids; \"i…"},"category_ids":{"type":"array","items":{"type":"number","description":"Category id (list_selling_categories / list_categories)."},"description":"Categories for apply_discount_to = \"category\" (csv string also accepted)."},"product_ids":{"type":"array","items":{"type":"number","description":"Product id (list_selling_products / list_products)."},"description":"Products for apply_discount_to = \"product\" (csv string also accepted)."},"channel_ids":{"type":"array","items":{"type":"number","description":"Channel id (list_selling_channels channel_id)."},"description":"Sales channels the code is valid on (csv also accepted)."},"order_type_ids":{"type":"array","items":{"type":"number","description":"Order type id (list_order_types)."},"description":"Order types (csv also accepted)."},"payment_methods":{"type":"array","items":{"type":"number","description":"Payment method id (list_selling_payment_methods [].id)."},"description":"Payment methods (csv also accepted)."},"is_auto_apply":{"type":"string","description":"1/true = auto apply, 0/false = cashier picks it."},"is_promotion":{"type":"string","description":"1/true = promotion."}},"required":["id","location_id"]},"module":"discounts","safety":"write"},{"name":"delete_discount","description":"DELETE /api/discounts/:id. Why: Permanently delete a discount (discount code); it disappears from the POS (get_user_extras) on its next refresh. There is no archive — to stop it temporarily use update_discount with is_active 0. Past orders keep their stored discount amounts. Id from list_discounts. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_discount\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/discounts/:id","why":"Permanently delete a discount (discount code); it disappears from the POS (get_user_extras) on its next refresh. There is no archive — to stop it temporarily use update_discount with is_active 0. Past orders keep their stored discount amounts. Id from list_discounts.","response":{"description":"200 → { status: true, message: \"Discount has been successfully deleted.\", data: \"<id>\" }. Unknown / already deleted id → 200 { status: false, message: \"Discount not found or already deleted.\", data: null } (check status, not the HTTP code). 500 → { status: false, message: \"An error occurred while deleting the discount.\", data: null }. The branch links (discounts_locations) are not removed.","fields":{"status":"boolean. true = deleted; false = not found or error.","message":"string. \"Discount has been successfully deleted.\" / \"Discount not found or already deleted.\" / \"An error occurred while deleting the discount.\"","data":"string | null. The deleted id as a STRING (echo of the path) on success; null otherwise."},"example_request":{"id":2400},"example":{"status":true,"message":"Discount has been successfully deleted.","data":"2400"},"master_rules":["The dashboard deletes from the list row's trash icon after a confirm dialog, then shows response.message and refetches list_discounts — it shows the message even when status is false. Always confirm with the user first, then check status.","Deleting removes the code for every branch at once. To stop it at one branch only, update_discount with that branch removed from location_id; to pause it, update_discount is_active 0."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Discount id (list_discounts data[].id)."}},"required":["id"]},"module":"discounts","safety":"destructive"},{"name":"list_selling_payment_methods","description":"GET /api/v2/payment-methods. Why: Tenders the branch can take (Cash, Card, Wallet…) — what web-pos loads for checkout, change-payment and refund. The method NAME is the identity: it is the key in the order's payment_method list, e.g. [{ \"CASH\": 30 }]. Not paginated. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_selling_payment_methods\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/payment-methods","why":"Tenders the branch can take (Cash, Card, Wallet…) — what web-pos loads for checkout, change-payment and refund. The method NAME is the identity: it is the key in the order's payment_method list, e.g. [{ \"CASH\": 30 }]. Not paginated.","response":{"description":"A BARE JSON ARRAY of payment methods (no status/data envelope), NOT sorted, and NOT filtered by is_enable — filter it yourself. Rows = methods assigned to the branch (payment_methods_locations) or created for it; if none are assigned, all of the tenant's methods (then location_id is an array). Error: HTTP 500 { error, details } or 401 { error } when not authenticated.","fields":{"[]":"array. Payment methods. [] = none.","[].name":"string. THE IDENTITY. Show it and use it as the key in the order's payment_method list. web-pos trims + UPPERCASES it (single tender → { \"CASH\": amount }; split/multi tender → lowercase keys). Names are matched case-insensitively.","[].is_enable":"0 | 1. Only offer methods with is_enable = 1 (web-pos filters strictly === 1). The server returns disabled ones too.","[].type":"string | null. Tender kind (e.g. \"cash\", \"wallet\", \"card\"). Independent of name — live data has name \"card\" with type \"cash\". type \"cash\" = cash tender (change allowed); type \"wallet\" = needs a customer on the order.","[].id":"int. Method id. NOT sent on the order (the name is).","[].location_id":"int | int[]. Branch the row was returned for (array of branches in the all-tenant fallback). NOT used by web-pos.","[].priority":"int. NOT USED by web-pos — display priority (default 0); the server does not sort by it on this route.","[].customer_id":"int. NOT USED — tenant id.","[].sync_locations":"0 | 1. NOT USED — dashboard flag: method is shared to branches."},"master_rules":["Checkout list (web-pos): keep is_enable = 1 → remove duplicates by trimmed upper-case name (the LAST duplicate wins) → show in id order; the first one is pre-selected.","Cash = name \"cash\" or type \"cash\" (exact, case-insensitive): the customer may overpay and gets change. Any other tender is capped at the remaining amount.","Card terminal: only a method NAMED exactly \"card\" triggers the card-terminal (NGenius) flow; the approved payment is then recorded under the key \"network\". Out of scope for agents — record it as a normal tender.","Wallet = type \"wallet\" or name \"wallet\": a customer must be attached to the order (find or create one with list_pos_crm_users / create_pos_crm_user).","Zero-total orders are recorded as { \"cash\": 0 }. Split and multi-tender are several { name: amount } entries in the same list."],"example":[{"id":1632,"name":"card","is_enable":1,"priority":0,"type":"cash","customer_id":437,"location_id":907,"sync_locations":1},{"id":2135,"name":"Wallet","is_enable":1,"priority":0,"type":"wallet","customer_id":437,"location_id":907,"sync_locations":1}]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Optional."}},"required":[]},"module":"payment_methods","safety":"read"},{"name":"list_payment_methods","description":"GET /api/payment-methods. Why: Dashboard management list of tender types (Cash, Card, Wallet …) with the branches each is linked to — use it for ids (get/update/delete_payment_method), to audit which branches accept which methods, and to find methods linked to no branch. Not the POS selling view: web-pos reads GET /api/v2/payment-methods (list_selling_payment_methods), which defaults to the cashier's branch. Linkage: the payment method NAME is the key in an invoice's payment_method[] (e.g. [{\"cash\": 30}]) and is matched case-insensitively against the methods linked to the invoice's branch — so renaming or deleting a method changes what the POS can settle with. Branch availability lives in payment_methods_locations (location_id[] in these tools); on master tenants payment_methods.location_id is NULL. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_payment_methods\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/payment-methods","why":"Dashboard management list of tender types (Cash, Card, Wallet …) with the branches each is linked to — use it for ids (get/update/delete_payment_method), to audit which branches accept which methods, and to find methods linked to no branch. Not the POS selling view: web-pos reads GET /api/v2/payment-methods (list_selling_payment_methods), which defaults to the cashier's branch. Linkage: the payment method NAME is the key in an invoice's payment_method[] (e.g. [{\"cash\": 30}]) and is matched case-insensitively against the methods linked to the invoice's branch — so renaming or deleting a method changes what the POS can settle with. Branch availability lives in payment_methods_locations (location_id[] in these tools); on master tenants payment_methods.location_id is NULL.","response":{"description":"HTTP 200 bare array (no envelope). Tenant-scoped (customer_id from session). Shape of location_id depends on loc (see query.loc). Not paginated; no name search (a `k` param is ignored). Errors: 401 { error } no session; 500 { error: \"Failed to retrieve payment methods\", details }.","fields":{"[]":"array. One row per method (no loc) or per (method, branch) (with loc).","[].id":"number. Payment method id. Use as :id for get/update/delete_payment_method.","[].name":"string. Display name AND the key used in an invoice's payment_method[] (e.g. {\"card\": 30}); matched case-insensitively at the branch.","[].is_enable":"number | null. 1 = enabled, 0 = disabled, null = never set (created without is_enable). Not filtered server-side — disabled methods are still listed.","[].priority":"number. Sort priority (0 when unset). The unfiltered list is ordered by priority then name.","[].type":"string | null. Free-form tender type (seeded values \"cash\", \"card\"; also seen \"wallet\"). null when unset. Informational; the invoice uses name, not type.","[].customer_id":"number. Tenant id (always yours). Not needed.","[].location_id":"number[] | number. Without loc: array of branch ids from payment_methods_locations ([] = linked to no branch, so no POS can use it). With loc: the single matched branch id.","[].sync_locations":"number. 0/1 flag stored on the row (1 = created as a master method shared across branches). Not interpreted by these endpoints; update_payment_method resets it to 0 unless you resend 1.","error":"string. Only on 401/500.","details":"string. Only on 500."},"example":[{"id":1632,"name":"card","is_enable":1,"priority":0,"type":"cash","customer_id":437,"location_id":907,"sync_locations":1},{"id":2135,"name":"Wallet","is_enable":1,"priority":0,"type":"wallet","customer_id":437,"location_id":907,"sync_locations":1}]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id(s), csv (\"907,908\")."}},"required":[]},"module":"payment_methods","safety":"read"},{"name":"get_payment_method","description":"GET /api/payment-methods/:id. Why: Load one payment method with its full branch list before update_payment_method (update is a full replace — resend is_enable, priority, type, sync_locations and the complete location_id list). Linkage: the payment method NAME is the key in an invoice's payment_method[] (e.g. [{\"cash\": 30}]) and is matched case-insensitively against the methods linked to the invoice's branch — so renaming or deleting a method changes what the POS can settle with. Branch availability lives in payment_methods_locations (location_id[] in these tools); on master tenants payment_methods.location_id is NULL. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_payment_method\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/payment-methods/:id","why":"Load one payment method with its full branch list before update_payment_method (update is a full replace — resend is_enable, priority, type, sync_locations and the complete location_id list). Linkage: the payment method NAME is the key in an invoice's payment_method[] (e.g. [{\"cash\": 30}]) and is matched case-insensitively against the methods linked to the invoice's branch — so renaming or deleting a method changes what the POS can settle with. Branch availability lives in payment_methods_locations (location_id[] in these tools); on master tenants payment_methods.location_id is NULL.","response":{"description":"HTTP 200 [row] — a ONE-ELEMENT ARRAY, not an object. Row = SELECT * FROM payment_methods WHERE id AND customer_id, with location_id replaced by the branch ids from payment_methods_locations. Read-only. Errors: 404 { success: false, message: \"Payment method not found\" }; 500 { success: false, message: \"Failed to retrieve payment method\" }.","fields":{"[0]":"object. The payment method.","[0].id":"number. Payment method id. Use as :id for get/update/delete_payment_method.","[0].name":"string. Display name AND the key used in an invoice's payment_method[] (e.g. {\"card\": 30}); matched case-insensitively at the branch.","[0].is_enable":"number | null. 1 = enabled, 0 = disabled, null = never set (created without is_enable). Not filtered server-side — disabled methods are still listed.","[0].priority":"number. Sort priority (0 when unset). The unfiltered list is ordered by priority then name.","[0].type":"string | null. Free-form tender type (seeded values \"cash\", \"card\"; also seen \"wallet\"). null when unset. Informational; the invoice uses name, not type.","[0].customer_id":"number. Tenant id (always yours). Not needed.","[0].location_id":"number[]. Branch ids this method is enabled at ([] = none). Resend the full list to update_payment_method.","[0].sync_locations":"number. 0/1 flag stored on the row (1 = created as a master method shared across branches). Not interpreted by these endpoints; update_payment_method resets it to 0 unless you resend 1.","success":"boolean. Only on 404/500 (false).","message":"string. Only on 404/500."},"example":[{"id":2135,"name":"Wallet","customer_id":437,"location_id":[907,908,909,910,922,1100,1101,1102,1117,1131],"is_enable":1,"priority":0,"type":"wallet","sync_locations":1}]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Payment method id (list_payment_methods)."}},"required":["id"]},"module":"payment_methods","safety":"read"},{"name":"create_payment_method","description":"POST /api/payment-methods. Why: Add a tender type and enable it at chosen branches. The name becomes the invoice payment_method[] key, so match what cashiers/integrations will send (case-insensitive). Check list_payment_methods (no loc) first — names must be unique across the tenant. Linkage: the payment method NAME is the key in an invoice's payment_method[] (e.g. [{\"cash\": 30}]) and is matched case-insensitively against the methods linked to the invoice's branch — so renaming or deleting a method changes what the POS can settle with. Branch availability lives in payment_methods_locations (location_id[] in these tools); on master tenants payment_methods.location_id is NULL. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_payment_method\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/payment-methods","why":"Add a tender type and enable it at chosen branches. The name becomes the invoice payment_method[] key, so match what cashiers/integrations will send (case-insensitive). Check list_payment_methods (no loc) first — names must be unique across the tenant. Linkage: the payment method NAME is the key in an invoice's payment_method[] (e.g. [{\"cash\": 30}]) and is matched case-insensitively against the methods linked to the invoice's branch — so renaming or deleting a method changes what the POS can settle with. Branch availability lives in payment_methods_locations (location_id[] in these tools); on master tenants payment_methods.location_id is NULL.","response":{"description":"HTTP 201 { success: true, message, data } where data echoes the request body plus id, customer_id and the parsed location_id array (not a DB re-read). Side effects: INSERT payment_methods (location_id NULL on master) + one payment_methods_locations row per branch. Errors ({ success: false, message }): 400 \"Payment method name is required\"; 400 \"Select at least one location\"; 400 \"A payment method with this name already exists\"; 500 \"Failed to create payment method\".","fields":{"success":"boolean. true on 201, false on errors.","message":"string. \"Payment method created successfully\" or the error text.","data.<each body field you sent>":"any. Echo of the request body (name trimmed). Unsent fields are absent even though stored as NULL/0.","data.id":"number. New payment method id.","data.customer_id":"number. Your tenant id.","data.location_id":"number[]. Parsed branch ids that were linked."},"example":{"success":true,"message":"Payment method created successfully","data":{"name":"Wallet","is_enable":1,"priority":3,"type":"wallet","sync_locations":1,"id":2140,"customer_id":437,"location_id":[907,908]}}},"parameters":{"type":"object","properties":{"name":{"type":"string","description":"Payment method name (trimmed; blank/omitted → 400 \"Payment method name is required\")."},"location_id":{"type":"array","items":{"type":"number","description":""},"description":"Branch ids to enable this method at, as an array, e.g."},"is_enable":{"type":"number","description":"1 = enabled, 0 = disabled."},"priority":{"type":"number","description":"Sort priority (lower first in the unfiltered list)."},"type":{"type":"string","description":"Free-form tender type, e.g."},"sync_locations":{"type":"number","description":"Master flag: 1 (or true / \"1\") = shared master method; anything else → 0."}},"required":["name","location_id"]},"module":"payment_methods","safety":"write"},{"name":"update_payment_method","description":"PUT /api/payment-methods/:id. Why: Rename, enable/disable, re-prioritise or change the branches of a payment method. FULL REPLACE: name, is_enable, priority, type and sync_locations are all overwritten (omitted → NULL/0), so call get_payment_method first and resend everything. Renaming changes the invoice payment_method[] key — invoices/integrations still sending the old name will no longer match. Linkage: the payment method NAME is the key in an invoice's payment_method[] (e.g. [{\"cash\": 30}]) and is matched case-insensitively against the methods linked to the invoice's branch — so renaming or deleting a method changes what the POS can settle with. Branch availability lives in payment_methods_locations (location_id[] in these tools); on master tenants payment_methods.location_id is NULL. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_payment_method\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/payment-methods/:id","why":"Rename, enable/disable, re-prioritise or change the branches of a payment method. FULL REPLACE: name, is_enable, priority, type and sync_locations are all overwritten (omitted → NULL/0), so call get_payment_method first and resend everything. Renaming changes the invoice payment_method[] key — invoices/integrations still sending the old name will no longer match. Linkage: the payment method NAME is the key in an invoice's payment_method[] (e.g. [{\"cash\": 30}]) and is matched case-insensitively against the methods linked to the invoice's branch — so renaming or deleting a method changes what the POS can settle with. Branch availability lives in payment_methods_locations (location_id[] in these tools); on master tenants payment_methods.location_id is NULL.","response":{"description":"HTTP 200 { success: true, message, data } where data echoes the request body plus id (string, from the URL), customer_id and parsed location_id (not a DB re-read). Side effects: UPDATE payment_methods SET name, is_enable, priority, type, location_id = NULL, sync_locations; then, if location_id was sent, delete links not in the list and insert new ones. Errors ({ success: false, message }): 400 \"Payment method name is required\"; 404 \"Payment method not found\"; 400 \"A payment method with this name already exists\"; 400 \"Select at least one location\" (row already updated — partial write); 500 \"Failed to update payment method\".","fields":{"success":"boolean. true on 200, false on errors.","message":"string. \"Payment method updated successfully\" or the error text.","data.<each body field you sent>":"any. Echo of the request body (name trimmed).","data.id":"string. The :id from the URL, as a string.","data.customer_id":"number. Your tenant id.","data.location_id":"number[]. Parsed branch ids sent ([] if location_id was omitted — links then unchanged)."},"example":{"success":true,"message":"Payment method updated successfully","data":{"name":"Wallet","is_enable":1,"priority":0,"type":"wallet","sync_locations":1,"id":"2135","customer_id":437,"location_id":[907,908,909]}}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Payment method id (list_payment_methods)."},"name":{"type":"string","description":"Payment method name (trimmed; blank/omitted → 400 \"Payment method name is required\")."},"location_id":{"type":"array","items":{"type":"number","description":""},"description":"FULL REPLACEMENT of the branch list, as an array of branch ids."},"is_enable":{"type":"number","description":"1 = enabled, 0 = disabled."},"priority":{"type":"number","description":"Sort priority (lower first in the unfiltered list)."},"type":{"type":"string","description":"Free-form tender type, e.g."},"sync_locations":{"type":"number","description":"Master flag: 1 (or true / \"1\") = shared master method; anything else → 0."}},"required":["id","name"]},"module":"payment_methods","safety":"write"},{"name":"delete_payment_method","description":"DELETE /api/payment-methods/:id. Why: Permanently delete a payment method and all its branch links. NO in-use check (the guard is commented out) — past invoices keep the name string, but no branch can settle with it any more. To stop using it at one branch, update_payment_method with a smaller location_id list; to pause it, set is_enable = 0. Confirm with the user first. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_payment_method\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/payment-methods/:id","why":"Permanently delete a payment method and all its branch links. NO in-use check (the guard is commented out) — past invoices keep the name string, but no branch can settle with it any more. To stop using it at one branch, update_payment_method with a smaller location_id list; to pause it, set is_enable = 0. Confirm with the user first.","response":{"description":"HTTP 200 { status: true, message } — envelope key is `status`, not `success`. Side effects: DELETE payment_methods_locations WHERE payment_methods_id AND customer_id, then DELETE payment_methods WHERE id AND customer_id. Errors ({ status: false, message }): 404 not found / not yours; 400 \"Payment method could not be deleted. Please try again.\" (0 rows deleted — links already removed); 500 generic error.","fields":{"status":"boolean. true when deleted, false on errors.","message":"string. e.g. \"Payment method \\\"Wallet\\\" deleted successfully.\" or the error text."},"example":{"status":true,"message":"Payment method \"Wallet\" deleted successfully."}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Payment method id (list_payment_methods)."}},"required":["id"]},"module":"payment_methods","safety":"destructive"},{"name":"list_selling_order_types","description":"GET /api/v2/order-types. Why: Order types (Dine-in, Takeaway, Delivery, Drive-thru…) the POS offers — what web-pos loads for the order-type chips and checkout step 1. The chosen type's TITLE (a free-text string) is what goes on the order as order_type; nothing uses the id or code. Tenant-wide list (not per branch). Not paginated. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_selling_order_types\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/order-types","why":"Order types (Dine-in, Takeaway, Delivery, Drive-thru…) the POS offers — what web-pos loads for the order-type chips and checkout step 1. The chosen type's TITLE (a free-text string) is what goes on the order as order_type; nothing uses the id or code. Tenant-wide list (not per branch). Not paginated.","response":{"description":"{ status, message: \"data found\", data: orderTypes[] } — every order type of the tenant (not filtered by branch, not sorted; web-pos shows them in id order). SIDE EFFECT: when a row has no icon_key the server writes a default one into the database during this GET. Error: HTTP 500 { status:false, message }.","fields":{"status":"boolean. true = success.","message":"string. \"data found\".","data[].id":"int. Order type id. NOT used on the order (web-pos only uses it as a list key).","data[].title":"string. THE VALUE: the order type name to show and to put on the order as order_type (e.g. \"dine-in\", \"takeaway\"). Free text, casing as stored.","data[].order_type_code":"string | null. NOT USED by web-pos — stable code (DINE_IN, TAKEAWAY, DELIVERY, DRIVE_THRU) for system defaults.","data[].is_system_default":"0 | 1. NOT USED by web-pos — 1 = one of the built-in types. It does NOT mean 'selected by default'.","data[].icon_key":"string | null. NOT USED by web-pos — Iconify icon id (e.g. \"carbon:delivery\").","data[].icon_url":"string | null. NOT USED by web-pos — PNG/other image URL for icon_key (null if no icon)."},"master_rules":["No default order type: web-pos starts with none selected and the cashier must pick one before paying (checkout step 1 is skipped only if a type was already picked on the cart).","Dine-in = title contains \"dine\" or \"dining\" (case-insensitive substring). Dine-in orders need a table (list_selling_floors → list_selling_tables) when SAVED (parked/sent to kitchen) and carry dining_guests; paying directly does not require a table. Other types (takeaway, delivery, drive-thru) have no special logic in web-pos (delivery needs no customer or address; delivery charges are a separate manual charge).","Put the title exactly as returned on the order (order_type). Do not send the id or order_type_code."],"example":{"status":true,"message":"data found","data":[{"id":1139,"title":"delivery","icon_key":"carbon:delivery","icon_url":"https://images.weserv.nl/?url=https%3A%2F%2Fapi.iconify.design%2Fcarbon%2Fdelivery.svg%3Fwidth%3D128%26height%3D128&output=png","order_type_code":"DELIVERY","is_system_default":1},{"id":1174,"title":"dine-in","icon_key":"boxicons:fork-knife","icon_url":"https://images.weserv.nl/?url=https%3A%2F%2Fapi.iconify.design%2Fboxicons%2Ffork-knife.svg%3Fwidth%3D128%26height%3D128&output=png","order_type_code":"DINE_IN","is_system_default":1},{"id":1129,"title":"drive-thru","icon_key":"ic:baseline-drive-eta","icon_url":"https://images.weserv.nl/?url=https%3A%2F%2Fapi.iconify.design%2Fic%2Fbaseline-drive-eta.svg%3Fwidth%3D128%26height%3D128&output=png","order_type_code":"DRIVE_THRU","is_system_default":1},{"id":1127,"title":"takeaway","icon_key":"boxicons:takeaway","icon_url":"https://images.weserv.nl/?url=https%3A%2F%2Fapi.iconify.design%2Fboxicons%2Ftakeaway.svg%3Fwidth%3D128%26height%3D128&output=png","order_type_code":"TAKEAWAY","is_system_default":1}]}},"parameters":{"type":"object","properties":{"icon_pixel_size":{"type":"number","description":"Optional."},"icon_format":{"type":"string","enum":["png","jpg","jpeg","webp","gif"],"description":"Optional."}},"required":[]},"module":"order_types","safety":"read"},{"name":"list_order_types","description":"GET /api/order-type. Why: Dashboard management list of the tenant's order types (dine-in, takeaway, delivery, drive-thru and custom ones) — use it to get ids for update_order_type / delete_order_type, check whether a name exists before create_order_type, and see which rows are protected system defaults. Not the POS selling view: web-pos reads GET /api/v2/order-types (list_selling_order_types), which returns the same rows as `title` plus a rendered icon_url. Order types are tenant-wide (order_type.customer_id) — NOT per branch; every branch sees the same list and there is no loc filter. The order type NAME (order_type here, `title` in GET /api/v2/order-types) is the string stored in an invoice's order_type; order_type_code is the fixed behaviour (DINE_IN / TAKEAWAY / DELIVERY / DRIVE_THRU) the POS uses to decide table/driver/address flow. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_order_types\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/order-type","why":"Dashboard management list of the tenant's order types (dine-in, takeaway, delivery, drive-thru and custom ones) — use it to get ids for update_order_type / delete_order_type, check whether a name exists before create_order_type, and see which rows are protected system defaults. Not the POS selling view: web-pos reads GET /api/v2/order-types (list_selling_order_types), which returns the same rows as `title` plus a rendered icon_url. Order types are tenant-wide (order_type.customer_id) — NOT per branch; every branch sees the same list and there is no loc filter. The order type NAME (order_type here, `title` in GET /api/v2/order-types) is the string stored in an invoice's order_type; order_type_code is the fixed behaviour (DINE_IN / TAKEAWAY / DELIVERY / DRIVE_THRU) the POS uses to decide table/driver/address flow.","response":{"description":"HTTP 200 { success: true, data: [row] } — every order_type row of the tenant (customer_id from the session), ORDER BY order_type ASC. No filters, no pagination, no branch scoping (order types are tenant-wide). Read-only, no side effects. Empty tenant → data: [].","fields":{"success":"boolean. Always true on 200.","data":"array. One row per order type.","data[].id":"number. Order type id. Use as :id for update_order_type / delete_order_type.","data[].order_type":"string. Display name / title. This exact string is what an invoice stores in order_type (it is `title` in the POS list GET /api/v2/order-types).","data[].order_type_code":"string. Behaviour code: DINE_IN | TAKEAWAY | DELIVERY | DRIVE_THRU (see list_order_type_codes). Fixed at creation; cannot be changed.","data[].is_system_default":"number. 1 = one of the 4 seeded defaults (takeaway, drive-thru, delivery, dine-in): cannot be edited or deleted (403). 0 = custom, editable.","data[].customer_id":"number. Tenant id (always yours). Not needed.","data[].icon_key":"string | null. Iconify key (prefix:name, e.g. \"carbon:delivery\") shown on the POS button."},"example":{"success":true,"data":[{"id":1139,"order_type":"delivery","order_type_code":"DELIVERY","is_system_default":1,"customer_id":437,"icon_key":"carbon:delivery"},{"id":1174,"order_type":"dine-in","order_type_code":"DINE_IN","is_system_default":1,"customer_id":437,"icon_key":"boxicons:fork-knife"}]}},"parameters":{"type":"object","properties":{},"required":[]},"module":"order_types","safety":"read"},{"name":"list_order_type_codes","description":"GET /api/order-type/codes. Why: The fixed set of behaviour codes accepted by create_order_type.order_type_code (DINE_IN, DRIVE_THRU, TAKEAWAY, DELIVERY). Call before creating a custom order type (e.g. \"Talabat\" with code DELIVERY) to pick its behaviour. Static list — same for every tenant. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_order_type_codes\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/order-type/codes","why":"The fixed set of behaviour codes accepted by create_order_type.order_type_code (DINE_IN, DRIVE_THRU, TAKEAWAY, DELIVERY). Call before creating a custom order type (e.g. \"Talabat\" with code DELIVERY) to pick its behaviour. Static list — same for every tenant.","response":{"description":"HTTP 200 { success: true, data: [{ code, default_label }] } — constant list from constants/defaultOrderTypes.js (4 entries), no DB read, no side effects.","fields":{"success":"boolean. Always true.","data":"array. The 4 allowed codes.","data[].code":"string. DINE_IN | DRIVE_THRU | TAKEAWAY | DELIVERY. Send as create_order_type.order_type_code.","data[].default_label":"string. Human label (Dine-in, Drive-thru, Takeaway, Delivery). Informational only — the order type's own name is what the POS shows."},"example":{"success":true,"data":[{"code":"DINE_IN","default_label":"Dine-in"},{"code":"DRIVE_THRU","default_label":"Drive-thru"},{"code":"TAKEAWAY","default_label":"Takeaway"},{"code":"DELIVERY","default_label":"Delivery"}]}},"parameters":{"type":"object","properties":{},"required":[]},"module":"order_types","safety":"read"},{"name":"create_order_type","description":"POST /api/order-type. Why: Add a custom order type for the whole tenant (all branches get it), e.g. \"Talabat\" behaving as DELIVERY. Its name becomes the order_type string invoices carry, so choose it deliberately. Check list_order_types first — names must be unique per tenant. Order types are tenant-wide (order_type.customer_id) — NOT per branch; every branch sees the same list and there is no loc filter. The order type NAME (order_type here, `title` in GET /api/v2/order-types) is the string stored in an invoice's order_type; order_type_code is the fixed behaviour (DINE_IN / TAKEAWAY / DELIVERY / DRIVE_THRU) the POS uses to decide table/driver/address flow. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_order_type\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/order-type","why":"Add a custom order type for the whole tenant (all branches get it), e.g. \"Talabat\" behaving as DELIVERY. Its name becomes the order_type string invoices carry, so choose it deliberately. Check list_order_types first — names must be unique per tenant. Order types are tenant-wide (order_type.customer_id) — NOT per branch; every branch sees the same list and there is no loc filter. The order type NAME (order_type here, `title` in GET /api/v2/order-types) is the string stored in an invoice's order_type; order_type_code is the fixed behaviour (DINE_IN / TAKEAWAY / DELIVERY / DRIVE_THRU) the POS uses to decide table/driver/address flow.","response":{"description":"HTTP 201 { success: true, message, data } on success. Inserts one order_type row (customer_id = tenant, is_system_default = 0); tenant-wide, no branch links. Validation order: 400 \"Order type name is required\" (blank name) → 400 \"A valid order type code (behavior) is required\" → 400 \"Order type with this name already exists\" (exact name match within tenant, name compared UNtrimmed) → 400 \"Icon is required\" (bad icon_key) → 500 \"Failed to create order type\". Errors are { success: false, message }.","fields":{"success":"boolean. true on 201, false on 400/500.","message":"string. \"Order type created successfully\" or the error text.","data.id":"number. New order type id — use for update_order_type / delete_order_type.","data.order_type":"string. Stored name (trimmed). This is the invoice order_type string.","data.icon_key":"string. Stored icon key.","data.order_type_code":"string. Stored behaviour code, upper-cased.","data.is_system_default":"number. Always 0 (custom rows are editable/deletable)."},"example":{"success":true,"message":"Order type created successfully","data":{"id":1201,"order_type":"Talabat","icon_key":"carbon:delivery","order_type_code":"DELIVERY","is_system_default":0}}},"parameters":{"type":"object","properties":{"order_type":{"type":"string","description":"Order type name shown on the POS and stored as the invoice order_type string, e.g."},"order_type_code":{"type":"string","enum":["DINE_IN","TAKEAWAY","DELIVERY","DRIVE_THRU"],"description":"Behaviour code (list_order_type_codes): DINE_IN | TAKEAWAY | DELIVERY | DRIVE_THRU."},"icon_key":{"type":"string","description":"Iconify icon key \"prefix:name\" (e.g."}},"required":["order_type","order_type_code","icon_key"]},"module":"order_types","safety":"write"},{"name":"update_order_type","description":"PUT /api/order-type/:id. Why: Rename a custom order type or change its icon. The 4 system defaults (is_system_default = 1) cannot be edited (403); the behaviour code can never change (delete and recreate instead). Renaming changes the order_type string that NEW invoices carry — old invoices keep the old name, so reports will show both. Send BOTH order_type and icon_key every time (full replace). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_order_type\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/order-type/:id","why":"Rename a custom order type or change its icon. The 4 system defaults (is_system_default = 1) cannot be edited (403); the behaviour code can never change (delete and recreate instead). Renaming changes the order_type string that NEW invoices carry — old invoices keep the old name, so reports will show both. Send BOTH order_type and icon_key every time (full replace).","response":{"description":"HTTP 200 { success: true, message, data } with the row re-read from the DB. Runs UPDATE order_type SET order_type, icon_key WHERE id AND customer_id AND is_system_default = 0. Errors ({ success: false, message }): 400 \"Invalid order type id\"; 404 \"Order type not found\" (wrong tenant/missing); 403 \"Default order types cannot be edited\"; 400 \"Order type code cannot be changed\" (any non-null order_type_code in the body); 400 \"Order type with this name already exists\"; 400 \"Icon is required\".","fields":{"success":"boolean. true on 200, false on errors.","message":"string. \"Order type updated successfully\" or the error text.","data.id":"number. The order type id.","data.order_type":"string. Name now stored.","data.icon_key":"string. Icon key now stored.","data.order_type_code":"string. Unchanged behaviour code.","data.is_system_default":"number. Always 0 here (defaults are rejected before update)."},"example":{"success":true,"message":"Order type updated successfully","data":{"id":1201,"order_type":"Talabat Delivery","icon_key":"carbon:delivery","order_type_code":"DELIVERY","is_system_default":0}}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Order type id (list_order_types)."},"order_type":{"type":"string","description":"New name (trimmed)."},"icon_key":{"type":"string","description":"Iconify key \"prefix:name\" (same rules as create)."}},"required":["id","order_type","icon_key"]},"module":"order_types","safety":"write"},{"name":"delete_order_type","description":"DELETE /api/order-type/:id. Why: Permanently delete a custom order type for the whole tenant (all branches lose it). System defaults (is_system_default = 1) cannot be deleted. There is NO in-use check — past invoices keep the name string, but the type disappears from the POS. Confirm with the user first. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_order_type\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/order-type/:id","why":"Permanently delete a custom order type for the whole tenant (all branches lose it). System defaults (is_system_default = 1) cannot be deleted. There is NO in-use check — past invoices keep the name string, but the type disappears from the POS. Confirm with the user first.","response":{"description":"HTTP 200 { status: true, message } — note the envelope key is `status`, not `success`. Runs DELETE FROM order_type WHERE id AND customer_id AND is_system_default = 0. Errors ({ status: false, message }): 400 \"Invalid order type id\"; 404 \"Order type not found\"; 403 \"Default order types cannot be deleted\"; 500 \"Failed to delete order type\".","fields":{"status":"boolean. true when the row was deleted, false on errors.","message":"string. \"Order type deleted successfully\" or the error text."},"example":{"status":true,"message":"Order type deleted successfully"}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Order type id (list_order_types, must have is_system_default = 0)."}},"required":["id"]},"module":"order_types","safety":"destructive"},{"name":"sync_invoice","description":"POST /api/v2/invoice-sync-v3. Why: Create or update a POS order — web-pos 'Pay' / 'Save order' (finalizeSyncPayload). One call = the whole order: header, every line, and (when paid) the tender. New uuid → new order on the session branch with order_no / customer_invoice_id; the same uuid → update of that OPEN order (full resend). status 'open' saves a pay-later / dine order (stock reserved), status 'completed' + is_paid 1 closes a paid sale (payments, stock commit, quota, loyalty, wallet, coupon usage, CRM totals, SMS, KDS push). The server stores the totals as sent — it computes no prices, VAT or totals and validates none of them. Feed it from list_selling_products / list_selling_modifiers / list_selling_addons / list_selling_brands / list_selling_order_types / list_selling_tables / list_pos_crm_users / list_selling_payment_methods, validate_coupon, validate_loyalty_burn and list_product_quota. Read it back with list_orders_history or list_dine_orders; afterwards cancel_invoice, refund_invoice or update_invoice_payment_methods. MCP wraps this one invoice into the array the backend expects. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"sync_invoice\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/v2/invoice-sync-v3","why":"Create or update a POS order — web-pos 'Pay' / 'Save order' (finalizeSyncPayload). One call = the whole order: header, every line, and (when paid) the tender. New uuid → new order on the session branch with order_no / customer_invoice_id; the same uuid → update of that OPEN order (full resend). status 'open' saves a pay-later / dine order (stock reserved), status 'completed' + is_paid 1 closes a paid sale (payments, stock commit, quota, loyalty, wallet, coupon usage, CRM totals, SMS, KDS push). The server stores the totals as sent — it computes no prices, VAT or totals and validates none of them. Feed it from list_selling_products / list_selling_modifiers / list_selling_addons / list_selling_brands / list_selling_order_types / list_selling_tables / list_pos_crm_users / list_selling_payment_methods, validate_coupon, validate_loyalty_burn and list_product_quota. Read it back with list_orders_history or list_dine_orders; afterwards cancel_invoice, refund_invoice or update_invoice_payment_methods. MCP wraps this one invoice into the array the backend expects.","response":{"description":"HTTP 200 (401 only when the session token is rejected). { status, message, data:[order] }: status true + 'order saved successfully' + data[0] = the saved order with items[] (read-back of the DB). status true + 'order is empty or might be already processing' = NOTHING saved (empty items or the uuid is being synced right now; data echoes the request). status false + 'This Order Already paid' = the uuid is already paid; data[0] = the stored order, unchanged. status false + 'Something went wrong. Please contact support' (+ error_code 'ER_LOCK_DEADLOCK') = rolled back, nothing saved.","fields":{"status":"boolean. true = saved (or idempotent replay). false = 'This Order Already paid' or 'Something went wrong. Please contact support'. CAUTION: 'order is empty or might be already processing' also has true but saved NOTHING — check data[0].oid.","message":"string. 'order saved successfully' | 'order is empty or might be already processing' | 'This Order Already paid' | 'Something went wrong. Please contact support'.","error_code":"string. Only on the generic error after 3 internal deadlock retries: 'ER_LOCK_DEADLOCK' (safe to retry with the same request_id).","data":"array. One entry (MCP sends one invoice). On 'order is empty…' it is the request echoed back (no oid).","data[].id":"string. Last 12 chars of uuid (display id).","data[].oid":"int. = customer_invoice_id.","data[].customer_invoice_id":"int. Branch-wide running invoice number (MAX+1 per branch), assigned on create for any status. Show it as the invoice number.","data[].order_no":"int. Daily order number of the branch (1, 2 … per created_at date), assigned on create.","data[].uuid":"string. The order uuid.","data[].status":"string. Saved status: 'open' | 'completed'.","data[].is_paid":"int. 1 / 0.","data[].items_number":"int | null. As sent.","data[].subtotal":"number | null. As sent.","data[].vat":"number | null. As sent.","data[].payable":"number | null. As sent (or reduced by discountamount by the legacy payable fix — see rules).","data[].discountamount":"number | null. As sent.","data[].discountpct":"number | null. As sent.","data[].items_discount_amount":"number. Σ line qty × discount_amount, computed by the server.","data[].row_discount":"int. Row-discount mode actually used (0/1).","data[].paid":"number | null. As sent.","data[].change":"number | null. As sent.","data[].tip_amount":"number | null. As sent.","data[].payment_method":"array | null. Saved tender, old format [{ \"<name>\": amount }] (name as sent). null = no payments rows.","data[].payment_methods":"array | null. Same rows, new format [{ \"<name>\": { amount, tip_amount } }].","data[].payments":"array | null. Same rows as [{ payment_method, amount, tip_amount }].","data[].note":"string | null. Order note.","data[].notes":"string[]. Extra order notes ([] when none).","data[].created_at":"int. Unix seconds.","data[].updated_at":"int. Unix seconds of the row's last change.","data[].paid_at":"int | null. Unix seconds.","data[].done_at":"int | null. NOT USED (kitchen done time, branch time zone).","data[].customer_id":"int. Tenant id.","data[].location_id":"int. Branch the order was saved on = the session branch (NOT a body field).","data[].user":"string. Login (email) of the session user that synced.","data[].created_by_user_id":"int | null. As sent.","data[].close_by_user_id":"int | null. As sent.","data[].modified_by_user_ids":"int[]. As sent.","data[].guest_name":"string. As sent ('' default).","data[].info_name":"string | null. Receipt header name.","data[].info_address":"string | null. Receipt header address.","data[].info_phone":"string | null. Receipt header phone.","data[].info_email":"string | null. Receipt header email.","data[].order_type":"string | null. Order type title.","data[].order_type_icon_key":"string | null. Iconify key (from the order type).","data[].order_type_icon":"string | null. PNG URL of that icon.","data[].table_id":"int. 0 = no table.","data[].dining_guests":"int | null. Covers.","data[].token_number":"int | null. Token.","data[].round":"int | null. Current round.","data[].brand_id":"int | null. Brand.","data[].channel_id":"int | null. As sent.","data[].channel_name":"string | null. As sent.","data[].crm_user_id":"int. 0 = no customer (unknown ids are reset to 0).","data[].crm_user_name":"string. Built by the server from the CRM customer.","data[].phone_number":"string. As sent or the customer's mobile.","data[].crm_delivery_area":"string | null. Delivery area.","data[].loyalty_mobile_number":"string. NOT USED ('' — not written by this route).","data[].loyalty_mobile_code":"string. NOT USED.","data[].earn_points":"number. Points earned on this order (server-computed; 0 unless paid with a loyalty customer).","data[].burn_points":"number. Points burned (capped to the card balance).","data[].burn_points_amount":"number. Money value of burn_points (server-computed).","data[].available_points":"number. Card balance after this order (server-computed on create).","data[].available_points_amount":"number. Money value of available_points.","data[].discount_id":"int | null. As sent.","data[].discount_reason":"string | null. As sent.","data[].coupon_id":"int. Header coupon (0 = none).","data[].coupon_code":"string | null. Header coupon code.","data[].coupon_amount":"number. Header coupon money.","data[].is_vat_on_discount_exclude":"int. As sent (0 default).","data[].is_discount_on_total":"int | null. As sent.","data[].charges_fee":"number. As sent (0).","data[].delivery_charges":"number. As sent (0).","data[].is_wallet_used":"int. 1 = wallet order.","data[].wallet_current_balance":"number. Only when is_wallet_used: request wallet_current_balance + payable.","data[].is_ticket_available":"int. 1 once completed (ticket validation), else as sent/0.","data[].is_kds_printed":"int. Kitchen ticket printed flag.","data[].is_invoice_printed":"int. NOT USED.","data[].printed_invoice_receipts":"int. Receipt print count (0).","data[].printed_kds_receipts":"int. KDS print count (0).","data[].period":"string | null. NOT USED.","data[].plate_number":"string | null. As sent.","data[].order_id":"int | null. NOT USED (aggregator).","data[].invoice_id":"int | null. NOT USED.","data[].aggregator_order_id":"string | null. NOT USED.","data[].exchange_rate":"number | null. NOT USED.","data[].paid_in_usd":"number | null. NOT USED.","data[].is_wastage":"int | null. NOT USED here (cancel disposition).","data[].is_accepted":"int | null. NOT USED (aggregator acceptance).","data[].external_order_ready":"int. NOT USED (0).","data[].rider_pickup_at":"int | null. NOT USED (delivery dispatch).","data[].rider_arrived_at":"int | null. NOT USED.","data[].is_qr_payment":"any. Echo of the request flag (undefined when not sent).","data[].items":"array. Saved lines of the order (main lines; lines of archived products are hidden). Removed lines stay with is_deleted = 1.","data[].items[].id":"int. invoice_items id — use it for refund_pos_invoice invoice_item_id.","data[].items[].identifier":"string. Line identifier (resend it on the next sync).","data[].items[].product_id":"int. Product.","data[].items[].name":"string. Line name.","data[].items[].name_localize":"string | null. Second-language name.","data[].items[].type":"string | null. Product category name (products.type).","data[].items[].image":"string | null. Product image URL.","data[].items[].is_weightage_item":"int. 1 = sold by weight (from the product).","data[].items[].unit_id":"int | null. Product unit.","data[].items[].qty":"number. Quantity.","data[].items[].price":"number. As sent.","data[].items[].actual_price":"number. As sent.","data[].items[].base_price":"number | null. As sent.","data[].items[].subtotal":"number. As sent (or actual_price × qty).","data[].items[].vat":"number | null. As sent.","data[].items[].line_total":"number | null. As sent.","data[].items[].line_vat":"number | null. As sent.","data[].items[].line_total_without_vat":"number | null. As sent.","data[].items[].discount_pct":"number. 0–100.","data[].items[].discount_amount":"number. Per-unit discount (server-computed unless sent > 0).","data[].items[].discount_id":"int | null. As sent.","data[].items[].discount_name":"string | null. As sent.","data[].items[].modifier":"array. Options as sent, each with discount_pct stamped.","data[].items[].extras":"any | null. null for master tenants.","data[].items[].is_addon":"int. 1 = add-on line.","data[].items[].addon_linked_with":"string | null. Parent line identifier.","data[].items[].is_combo":"int. 0 | 1 | 2.","data[].items[].combo_items":"array | null. Combo children (same fields as items[], plus combo_item_id / customer_id / location_id). Keep their id for the next sync.","data[].items[].note":"string | null. Line note.","data[].items[].is_prepared":"int | null. KDS prepared flag.","data[].items[].preparation_time":"int | null. Minutes.","data[].items[].is_updated":"int. 1 = qty changed / line added on the last update (KDS 'updated').","data[].items[].updated_qty":"int. Units added or removed on the last update.","data[].items[].round":"int | null. Round.","data[].items[].linked_uuid":"string | null. Round link.","data[].items[].original_linked_uuid":"string | null. Original round link.","data[].items[].item_status":"string. 'active'.","data[].items[].disposition_type":"string | null. Set only by cancel / void flows.","data[].items[].re":"string | null. Cancel reason (cancel flows).","data[].items[].ri":"int. Return-to-stock flag (cancel flows).","data[].items[].is_wastage":"int. Wastage flag (cancel flows).","data[].items[].is_deleted":"int. 1 = removed line.","data[].items[].deleted_qty":"number. Removed units.","data[].items[].deleted_at":"int | null. Unix seconds.","data[].items[].is_refunded":"int. Refund flag (refund flows).","data[].items[].refund_quantity":"int. Refunded units.","data[].items[].refund_reason":"string | null. Refund reason.","data[].items[].coupon_id":"int. Line coupon (0).","data[].items[].coupon_code":"string. Line coupon code ('').","data[].items[].coupon_amount":"number. Line coupon money (0).","data[].items[].label_id":"int | null. Label.","data[].items[].updated_by_user_ids":"int[]. Editors.","data[].items[].transferred_to":"int. NOT USED (table transfer).","data[].items[].transferred_to_uuid":"string | null. NOT USED.","data[].items[].transferred_quantity":"number. NOT USED.","data[].items[].transferred_to_uuid_list":"string[]. NOT USED.","data[].items[].transferred_quantities":"number[]. NOT USED.","data[].items[].transfer_recipients":"int[]. NOT USED.","data[].items[].invoice_uuid":"string. The order uuid.","data[].items[].created_at":"int. Unix seconds.","data[].items[].updated_at":"int. Unix seconds."},"master_rules":["Branch: the order is ALWAYS saved on the session branch (the API key user's home branch — the same branch every list_selling_* tool returns). There is no body field to choose another branch (a location_id in the body is overwritten by the server).","Build the lines from the selling tools: product_id / name / actual_price / is_combo from list_selling_products (channel price from its price_levels when you sell on a list_selling_channels channel), options from list_selling_modifiers (only the groups in the product's modifiers[]), add-on lines from list_selling_addons (is_addon 1 + addon_linked_with = parent identifier), brand_id from list_selling_brands, order_type = list_selling_order_types title, table_id from list_selling_tables (free tables: list_dine_orders), crm_user_id from list_pos_crm_users, tender names from list_selling_payment_methods (is_enable 1), coupon from validate_coupon, loyalty from validate_loyalty_burn. Check list_product_quota remaining_stock first — the server never refuses an order over quota.","BUILD 1 — line identity: every main line and every add-on row gets its own new UPPER-case v4 UUID as identifier (main lines also linked_uuid = identifier). Keep the same identifier on every later resend of that line — it is how the server matches lines.","BUILD 2 — line basics: product_id = list_selling_products id, name, type = the product's type (category name, \"General\" if empty), image, brand_id, qty (1 per new line; adding the identical product with identical options/add-ons raises qty instead of a new line; for is_weightage_item send the weight as qty), actual_price = the product's actual_price (VAT incl.; for is_open_price the typed price), price = actual_price (web-pos sends the GROSS unit price on main lines), round 1 for a new order, item_status \"active\", extras \"{}\", combo_items [] unless a combo.","BUILD 3 — options: modifier[] is a FLAT list of the chosen option items (not groups), each { id, name, modifier_id (from list_selling_modifiers), actual_price, quantity (per ONE unit of the line), sub_total = r(actual_price × quantity), vat = r(extractVat(sub_total)), price = r(sub_total − vat), vat_pct, item_type: \"modifier\" }. Respect the product link's min_allowed / max_allowed per group.","BUILD 4 — add-ons: each chosen add-on (list_selling_addons) is its OWN item row placed after all main rows: is_addon 1, addon_linked_with = the parent line's identifier (and the parent sends addon_linked_with = its own identifier; \"\" when it has no add-ons), product_id = add-on id, qty, actual_price, sub_total = subtotal = actual_price × qty, vat = r(extractVat(sub_total)), price = r(sub_total − vat). The add-on price is ALSO inside the parent line's totals (BUILD 5).","BUILD 5 — line money (flat VAT; VAT% = the branch config key vat from get_pos_configs, else business_config.vat from get_user_extras; all prices VAT-inclusive): extractVat(g) = g − g/(1 + VAT/100); r() = round half-even to decimal_points from get_user_extras (web-pos uses exactly that value; 2 when absent — the worked example uses 2). unit = r(actual_price + Σ option sub_total + Σ add-on actual_price×qty − r(actual_price × discount_pct/100)); sub_total = subtotal = line_total = r(unit × qty); line_vat = r(extractVat(sub_total)); line_total_without_vat = r(sub_total − line_vat); vat = line_vat; vat_pct = VAT. discount_pct is a manual line discount on the base price only, capped by the product's discount_pct.","BUILD 6 — order totals: T = Σ line_total of main lines (add-ons are already inside them); S = T + charges_fee + delivery_charges; discountamount = r(S × discountpct/100 + burn_points_amount); payable = r(max(0, S − discountamount)); vat = discountpct == 100 ? 0 : r(extractVat(payable)); subtotal = r(payable − vat). A basket coupon becomes discountpct (value coupon: value/S×100) plus coupon_id / coupon_code / coupon_amount (validate_coupon first; a basket coupon replaces any manual order discount). A loyalty burn (validate_loyalty_burn) adds burn_points_amount to discountamount and sends burn_points, burn_points_amount, discount_reason \"Loyalty discount\", loyalty_card_id.","BUILD 7 — payment (Pay now): payment_method = [{ \"<method name>\": amount }, …] with Σ amounts = payable (names from list_selling_payment_methods, matched case-insensitively; web-pos sends the UPPER-case name for one tender, lower-case for split). paid = amount tendered; change = paid − payable (only a cash tender may exceed; other tenders are capped at the remainder); tip_amount 0 unless a tip. Zero payable → [{ \"cash\": 0 }]. Wallet tender → a crm_user_id is required and is_wallet_used 1.","BUILD 8 — header values web-pos sends: order_type = list_selling_order_types title; dine-in → table_id + dining_guests (guests entered, else the table's people_size), otherwise table_id 0; brand_id + info_name/info_address/info_email/info_phone from list_selling_brands; channel_id 0 and channel_name \"\" (POS); crm_user_id + crm_user_name when a customer is attached; created_by_user_id and close_by_user_id = the cashier (list_pos_users), user = cashier email; created_at / updated_at / paid_at = unix seconds; period \"M/d/yyyy-HHmmss\"; time_zone \"GMT\" with seconds_from_gmt of the branch; items_number = products_count = number of main lines, items_count = main lines + add-on rows + option items; exchange_rate 50 on paid orders; other flags 0. Kitchen: web-pos sends is_accepted 0 (this tool does not send it) — either way the web-pos KDS shows the order as PENDING until it is accepted with update_kds_order_status (status 1); the items' station is their type (category name).","BUILD 9 — combos (is_combo 1 = fixed price, 2 = per-child price): ONE parent line, type \"combo\", actual_price = combo price (+ each child's combo_price when is_combo 2); the chosen products go in combo_items[] (one child per unit, qty 1, own identifier, created_at); pick at most max_allowed per slot from the product's categories[].","BUILD 10 — worked example (VAT 5%): product 10 + one option 5 + one add-on 3, qty 1, dine-in, cash 20 → main line: actual_price 10, sub_total/line_total 18, line_vat 0.86, line_total_without_vat 17.14, modifier [{…, actual_price 5, quantity 1, sub_total 5, vat 0.24, price 4.76}]; add-on row: is_addon 1, actual_price 3, sub_total 3, vat 0.14, price 2.86; header: payable 18, vat 0.86, subtotal 17.14, discountamount 0, payment_method [{\"CASH\": 18}], paid 20, change 2, status \"completed\", is_paid 1.","Money is 100% client-computed: the server never recomputes or validates subtotal, vat, payable, line subtotal/vat, discounts, coupons, loyalty money or tips, and never checks Σ payment_method against payable or paid. There is NO validation error for wrong totals — compute them right. VAT (flat % of the branch, VAT-inclusive prices): line vat = subtotal − subtotal / (1 + pct/100); order vat = Σ line vat; order subtotal = payable − vat. Round to 2 dp.","Only server math: line discount_amount = base × discount_pct / 100 per unit (unless you send discount_amount > 0), items_discount_amount = Σ qty × discount_amount, subtotal = actual_price × qty when a line subtotal is 0/missing, loyalty earn/burn points, wallet amount = payable, order_no / customer_invoice_id.","Legacy payable fix: when is_paid = 1 and payable ≠ Σ payment_method amounts and a discount is set, and payable − discountamount equals that sum, the server saves payable − discountamount. Keep payable = Σ tender (minus change) to avoid it.","Pay now (one call): new uuid + new request_id, status 'completed', is_paid 1, items, subtotal / vat / payable, payment_method, paid, change, paid_at, created_at. Effects: order_no + customer_invoice_id assigned, payments rows written, master stock committed (queued), quota counted, cost snapshot, loyalty earn/burn + card update, wallet booking (is_wallet_used), coupon usage +1, CRM customer totals, SMS receipt (branch SMS on + phone), KDS device push 'New order N', Xero / CrossVal / Kiply sync.","Save open (pay later / dine): new uuid, status 'open', is_paid 0, no payment_method. Effects: numbers assigned, master stock RESERVED (queued), quota counted, KDS push, CRM totals, coupon usage +1 if coupon_id. The order appears in list_orders_history (status open) and, with table_id, on list_dine_orders.","Update an open order (add / change lines, then pay): send the SAME uuid, a NEW request_id and the FULL order again — every header column except created_at, shift_id and brand_id is overwritten with what you send (an omitted field becomes NULL / default: notes, guest_name, tip_amount, crm_user_id, created_by_user_id…). Resend every existing line with its identifier (from the previous response items[].identifier; combo children with their id). Changed/new lines set invoices.is_updated and reset is_kds_printed (KDS shows the update). Paying = the same call with status 'completed', is_paid 1 and the tender: stock is committed once (open → completed).","Removing a line: lines left out of items are NOT deleted (they stay on the order and in its totals on receipts) although their quota count is reversed. To remove a line keep it in items with is_deleted 1 and deleted_qty = removed units, and set its qty to what remains (0 when fully removed) so master stock is released too; recompute the totals without it.","Paid orders are frozen: syncing a uuid that is already saved with is_paid 1 changes nothing and answers status false 'This Order Already paid' with the stored order. Use update_invoice_payment_methods (tender), refund_invoice (money back) or cancel_invoice (void) instead.","Idempotency: request_id is cached 24 h after a successful save; a retry with it returns the stored order (status true) without saving again. The uuid is locked while a sync runs: a parallel call for the same uuid is skipped and answers status true 'order is empty or might be already processing' with no oid — call list_orders_history before retrying. An empty items list gives the same no-op answer.","Stock (tenants with master inventory): reserve / adjust / commit jobs are queued after the save; failures are only logged (the order stays saved). Combo children additionally write legacy inventory rows directly.","Loyalty (paid orders with crm_user_id and an active program where the customer has a card): stamp program → +1 stamp, or redeem the card when discount_reason = 'Loyalty discount'; point program → earn from payable, burn = burn_points (capped). Points are applied once, when the order becomes paid. The money of a burn / redemption must already be off payable (discountamount).","Times are Unix SECONDS (created_at, paid_at, items[].created_at / updated_at / deleted_at). Omitted created_at = now.","Confirm the order (lines, total, tender) with the user before calling — a paid order cannot be edited, only refunded or cancelled. Afterwards read it back with list_orders_history (or list_dine_orders for open dine orders)."],"caveats":"Also read by the server but not exposed (omit them): location_id / customer_id (overwritten by the session), payment_methods and payments (alternative tender arrays; payment_methods wins over payment_method when both are sent), items_discount (legacy items discount, used only when lines give 0), available_points / available_points_amount (overwritten by the loyalty calculation on create), order_type_icon / order_type_icon_url / order_type_icon_key (icon override; derived from order_type otherwise), period, order_id, aggregator_order_id, exchange_rate, paid_in_usd, is_accepted, external_order_ready, is_wastage, is_ticket_available, printed_invoice_receipts, printed_kds_receipts, rider_pickup_at, rider_arrived_at (delivery dispatch), is_qr_payment (creates a Niqodi card payment link), id (web-pos puts the table id there; only logged as the quota history invoice_id), customer_invoice_id / oid (stock-job fallback), ri / re with status 'canceled' (queues a background cancel — use cancel_invoice), dryrun_error (test hook: aborts the save); line fields is_refunded / refund_quantity / refund_reason, transferred_to / transferred_to_uuid / transferred_quantity / transferred_to_uuid_list / transferred_quantities / transfer_recipients (table transfer), disposition_type / disposition_reason / re, is_wastage, item_price_after_discount (overwritten). NOT read at all (do not send): header updated_at, crm_user_name, earn_points, burn_points_amount, loyalty_card_id, items_vat_amount / items_subtotal / items_total / gross_total / net_total. Errors come back as HTTP 200 with status false (the MCP call itself succeeds) — always read status and data[0].oid. Known server issues: update push to KDS devices never fires (invoiceSyncV3.js:3419 tests req.body.status on the array); coupon usage is counted on every sync of the order and never for coupons without usage_limit; lines left out are not deleted but lose their quota count; a line without its old identifier is duplicated; combo children without their id are duplicated on every update and a combo child with a non-string extras value makes an update fail; lines with is_deleted 1 keep their master stock while qty > 0.","example_request":{"uuid":"3f6c1d2a-8b4e-4c7a-9e21-5d0b7a6c4e19","request_id":"b1e0c7d4-2f5a-4e8b-a6c3-9d7e1f0a2b35","status":"completed","is_paid":1,"created_at":1790242200,"paid_at":1790242200,"order_type":"Takeaway","items_number":1,"items":[{"identifier":"9a4f0c3e-6d21-4b8a-8e5f-2c7d1b0a9e44","addon_linked_with":"9a4f0c3e-6d21-4b8a-8e5f-2c7d1b0a9e44","product_id":257506,"name":"Flat White","qty":1,"price":11,"actual_price":11,"subtotal":11,"vat":0.52,"vat_pct":5,"discount_pct":0,"is_addon":0,"modifier":[],"created_at":1790242200,"updated_at":1790242200}],"subtotal":10.48,"vat":0.52,"payable":11,"discountamount":0,"discountpct":0,"paid":11,"change":0,"tip_amount":0,"payment_method":[{"CASH":11}],"guest_name":"Walk-in","app_version":"mcp-1.0","os_type":"mcp"},"example":{"status":true,"message":"order saved successfully","data":[{"id":"5d0b7a6c4e19","oid":25,"customer_invoice_id":25,"items_number":1,"aggregator_order_id":null,"exchange_rate":null,"paid_in_usd":null,"is_wastage":null,"is_accepted":null,"external_order_ready":0,"is_discount_on_total":null,"subtotal":10.48,"vat":0.52,"payable":11,"discountamount":0,"items_discount_amount":0,"is_ticket_available":1,"is_wallet_used":0,"discountpct":0,"paid":11,"change":0,"note":null,"uuid":"3f6c1d2a-8b4e-4c7a-9e21-5d0b7a6c4e19","created_at":1790242200,"updated_at":1790242201,"customer_id":437,"location_id":907,"guest_name":"Walk-in","info_name":null,"info_address":null,"info_phone":null,"info_email":null,"status":"completed","user":"owner@example.com","table_id":0,"order_id":null,"payment_method":[{"CASH":11}],"earn_points":0,"burn_points":0,"burn_points_amount":0,"available_points":0,"available_points_amount":0,"charges_fee":0,"delivery_charges":0,"phone_number":"","loyalty_mobile_number":"","loyalty_mobile_code":"","crm_delivery_area":null,"row_discount":0,"invoice_id":null,"done_at":null,"paid_at":1790242200,"period":null,"is_kds_printed":0,"is_invoice_printed":0,"plate_number":null,"crm_user_id":0,"crm_user_name":"","order_no":3,"token_number":null,"brand_id":null,"order_type":"Takeaway","tip_amount":0,"created_by_user_id":null,"close_by_user_id":null,"modified_by_user_ids":[],"channel_id":null,"is_vat_on_discount_exclude":0,"discount_id":null,"dining_guests":null,"channel_name":null,"is_paid":1,"discount_reason":null,"coupon_id":0,"coupon_amount":0,"coupon_code":null,"printed_invoice_receipts":0,"printed_kds_receipts":0,"round":null,"rider_pickup_at":null,"rider_arrived_at":null,"order_type_icon_key":"boxicons:takeaway","notes":[],"order_type_icon":"https://images.weserv.nl/?url=https%3A%2F%2Fapi.iconify.design%2Fboxicons%2Ftakeaway.svg%3Fwidth%3D128%26height%3D128&output=png","payment_methods":[{"CASH":{"amount":11,"tip_amount":null}}],"payments":[{"payment_method":"CASH","amount":11,"tip_amount":0}],"items":[{"id":25504190,"identifier":"9a4f0c3e-6d21-4b8a-8e5f-2c7d1b0a9e44","name":"Flat White","name_localize":null,"base_price":null,"actual_price":11,"preparation_time":null,"price":11,"qty":1,"is_weightage_item":0,"unit_id":0,"subtotal":11,"line_total":null,"line_vat":null,"line_total_without_vat":null,"invoice_uuid":"3f6c1d2a-8b4e-4c7a-9e21-5d0b7a6c4e19","product_id":257506,"extras":null,"vat":0.52,"is_updated":0,"updated_qty":0,"is_addon":0,"is_refunded":0,"refund_quantity":0,"refund_reason":null,"note":null,"addon_linked_with":"9a4f0c3e-6d21-4b8a-8e5f-2c7d1b0a9e44","discount_pct":0,"discount_amount":0,"modifier":[],"transferred_to":null,"transferred_to_uuid":null,"transferred_quantity":null,"transferred_to_uuid_list":[],"transferred_quantities":[],"transfer_recipients":[],"created_at":1790242200,"updated_at":1790242200,"is_prepared":null,"type":"Espresso-Based Drinks","label_id":50,"item_status":"active","linked_uuid":null,"original_linked_uuid":null,"disposition_type":null,"image":null,"is_combo":null,"is_deleted":null,"deleted_at":null,"deleted_qty":null,"coupon_amount":0,"coupon_id":0,"coupon_code":"","updated_by_user_ids":[],"re":null,"ri":0,"is_wastage":0,"discount_id":null,"discount_name":null,"round":null,"combo_items":null}]}]}},"parameters":{"type":"object","properties":{"uuid":{"type":"string","description":"Required."},"request_id":{"type":"string","description":"Optional but recommended."},"status":{"type":"string","enum":["open","completed"],"description":"Required."},"is_paid":{"type":"number","description":"Optional."},"items":{"type":"array","items":{"type":"object","properties":{"identifier":{"type":"string","description":"Required in practice."},"product_id":{"type":"number","description":"Required."},"name":{"type":"string","description":"Required."},"name_localize":{"type":"string","description":"Optional."},"qty":{"type":"number","description":"Required."},"price":{"type":"number","description":"Required."},"actual_price":{"type":"number","description":"Required."},"base_price":{"type":"number","description":"Optional."},"subtotal":{"type":"number","description":"Required."},"vat":{"type":"number","description":"Optional."},"vat_pct":{"type":"number","description":"Optional."},"vat_name":{"type":"string","description":"Optional."},"line_total":{"type":"number","description":"Optional."},"line_vat":{"type":"number","description":"Optional."},"line_total_without_vat":{"type":"number","description":"Optional."},"discount_pct":{"type":"number","description":"Optional."},"discount_amount":{"type":"number","description":"Optional."},"discount_id":{"type":"number","description":"Optional."},"discount_name":{"type":"string","description":"Optional."},"modifier":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number","description":"Required."},"modifier_item_id":{"type":"number","description":"Optional."},"modifier_id":{"type":"number","description":"Optional."},"name":{"type":"string","description":"Required."},"actual_price":{"type":"number","description":"Required."},"price":{"type":"number","description":"Optional."},"quantity":{"type":"number","description":"Required."}},"required":["id","name","actual_price","quantity"],"description":""},"description":"Optional."},"extras":{"type":"array","items":{"type":"object","properties":{},"description":"Not used for master tenants."},"description":"Master-location tenants: send [] or omit (legacy non-master ingredient map)."},"is_addon":{"type":"number","description":"Optional."},"addon_linked_with":{"type":"string","description":"Optional."},"is_combo":{"type":"number","description":"Optional."},"combo_items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number","description":"invoice_items id of this child from the previous sync_invoice response (items[].combo_items[].id)."},"product_id":{"type":"number","description":"Required."},"name":{"type":"string","description":"Required."},"name_localize":{"type":"string","description":"Optional."},"qty":{"type":"number","description":"Required."},"price":{"type":"number","description":"Required."},"actual_price":{"type":"number","description":"Optional."},"base_price":{"type":"number","description":"Optional."},"subtotal":{"type":"number","description":"Optional."},"vat":{"type":"number","description":"Optional."},"line_total":{"type":"number","description":"Optional."},"line_vat":{"type":"number","description":"Optional."},"line_total_without_vat":{"type":"number","description":"Optional."},"discount_pct":{"type":"number","description":"Optional."},"discount_amount":{"type":"number","description":"Optional."},"modifier":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number","description":"Required."},"modifier_item_id":{"type":"number","description":"Optional."},"modifier_id":{"type":"number","description":"Optional."},"name":{"type":"string","description":"Required."},"actual_price":{"type":"number","description":"Required."},"price":{"type":"number","description":"Optional."},"quantity":{"type":"number","description":"Required."}},"required":["id","name","actual_price","quantity"],"description":""},"description":"Optional."},"note":{"type":"string","description":"Optional."},"round":{"type":"number","description":"Optional."},"label_id":{"type":"number","description":"Optional."},"created_at":{"type":"number","description":"Optional."},"updated_at":{"type":"number","description":"Optional."}},"required":["product_id","name","qty","price"],"description":""},"description":"Only when is_combo = 1 | 2: the combo's child lines (same meaning as items[] fields; subset below)."},"note":{"type":"string","description":"Optional."},"is_prepared":{"type":"number","description":"Optional."},"preparation_time":{"type":"number","description":"Optional."},"round":{"type":"number","description":"Optional."},"linked_uuid":{"type":"string","description":"Optional."},"original_linked_uuid":{"type":"string","description":"Optional."},"item_status":{"type":"string","enum":["active"],"description":"Optional."},"is_deleted":{"type":"number","description":"Optional."},"deleted_qty":{"type":"number","description":"Optional."},"deleted_at":{"type":"number","description":"Optional."},"coupon_id":{"type":"number","description":"Optional."},"coupon_code":{"type":"string","description":"Optional."},"coupon_amount":{"type":"number","description":"Optional."},"brand_id":{"type":"number","description":"Optional."},"label_id":{"type":"number","description":"Optional."},"product_recipe_cost":{"type":"number","description":"Optional."},"created_at":{"type":"number","description":"Optional."},"updated_at":{"type":"number","description":"Optional."},"updated_by_user_ids":{"type":"array","items":{"type":"number","description":"User id."},"description":"Optional."},"id":{"type":"number","description":"invoice_items id from a previous response (items[].id)."}},"required":["identifier","product_id","name","qty","price","actual_price","subtotal"],"description":""},"description":"Required."},"items_number":{"type":"number","description":"Optional."},"subtotal":{"type":"number","description":"Required."},"vat":{"type":"number","description":"Required."},"payable":{"type":"number","description":"Required."},"discountamount":{"type":"number","description":"Optional."},"discountpct":{"type":"number","description":"Optional."},"discount_id":{"type":"number","description":"Optional."},"discount_reason":{"type":"string","description":"Optional."},"is_vat_on_discount_exclude":{"type":"number","description":"Optional."},"is_discount_on_total":{"type":"number","description":"Optional."},"row_discount":{"type":"number","description":"Optional."},"charges_fee":{"type":"number","description":"Optional."},"delivery_charges":{"type":"number","description":"Optional."},"paid":{"type":"number","description":"Optional (send when is_paid = 1)."},"change":{"type":"number","description":"Optional."},"tip_amount":{"type":"number","description":"Optional."},"payment_method":{"type":"array","items":{"type":"string","description":"{ \"<method name>\": amount | { amount, tip_amount } }"},"description":"Tender rows (send with is_paid = 1)."},"card_digits":{"type":"string","description":"Optional."},"is_wallet_used":{"type":"number","description":"Optional."},"wallet_current_balance":{"type":"number","description":"Optional."},"created_at":{"type":"number","description":"Optional."},"paid_at":{"type":"number","description":"Optional (send when is_paid = 1)."},"order_type":{"type":"string","description":"Optional."},"table_id":{"type":"number","description":"Optional."},"dining_guests":{"type":"number","description":"Optional."},"token_number":{"type":"number","description":"Optional."},"round":{"type":"number","description":"Optional."},"brand_id":{"type":"number","description":"Optional."},"channel_id":{"type":"number","description":"Optional."},"channel_name":{"type":"string","description":"Optional."},"shift_id":{"type":"number","description":"Optional."},"guest_name":{"type":"string","description":"Optional."},"note":{"type":"string","description":"Optional."},"notes":{"type":"array","items":{"type":"string","description":"One note."},"description":"Optional."},"info_name":{"type":"string","description":"Optional."},"info_address":{"type":"string","description":"Optional."},"info_phone":{"type":"string","description":"Optional."},"info_email":{"type":"string","description":"Optional."},"phone_number":{"type":"string","description":"Optional."},"crm_user_id":{"type":"number","description":"Optional."},"crm_delivery_area":{"type":"string","description":"Optional."},"plate_number":{"type":"string","description":"Optional."},"coupon_id":{"type":"number","description":"Optional."},"coupon_code":{"type":"string","description":"Optional."},"coupon_amount":{"type":"number","description":"Optional."},"burn_points":{"type":"number","description":"Optional."},"created_by_user_id":{"type":"number","description":"Optional."},"close_by_user_id":{"type":"number","description":"Optional."},"modified_by_user_ids":{"type":"array","items":{"type":"number","description":"User id."},"description":"Optional."},"is_kds_printed":{"type":"number","description":"Optional, read on CREATE only."},"app_version":{"type":"string","description":"Optional."},"app_os_version":{"type":"string","description":"Optional."},"build_number":{"type":"string","description":"Optional."},"os_type":{"type":"string","description":"Optional."}},"required":["uuid","status","items","subtotal","vat","payable"]},"module":"orders","safety":"write"},{"name":"cancel_invoice","description":"PUT /api/v2/invoice-cancel/:uuid. Why: Void / cancel an order — the web-pos 'Cancel Order' button (order history: status open, opened, completed or partially_refunded) and 'Void Order' on an open dine table. Works on OPEN (unpaid) and COMPLETED (paid) orders. Sets invoices.status = 'cancelled' (the order drops out of sales, list_dine_orders and list_kds_orders), releases or returns the order's stock (master-inventory tenants), books every payment of the order as a cash-out (withdrawal), reverses a wallet payment, gives product-quota units back (list_product_quota), refreshes the CRM customer's totals and tells Grubtech / Limetray / Kiply. It CANNOT be undone and a cancelled order can no longer be refunded or have its payment changed. Use refund_pos_invoice (pending — route not deployed) instead when the sale stands but money (all or some lines) goes back to the guest. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"cancel_invoice\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/v2/invoice-cancel/:uuid","why":"Void / cancel an order — the web-pos 'Cancel Order' button (order history: status open, opened, completed or partially_refunded) and 'Void Order' on an open dine table. Works on OPEN (unpaid) and COMPLETED (paid) orders. Sets invoices.status = 'cancelled' (the order drops out of sales, list_dine_orders and list_kds_orders), releases or returns the order's stock (master-inventory tenants), books every payment of the order as a cash-out (withdrawal), reverses a wallet payment, gives product-quota units back (list_product_quota), refreshes the CRM customer's totals and tells Grubtech / Limetray / Kiply. It CANNOT be undone and a cancelled order can no longer be refunded or have its payment changed. Use refund_pos_invoice (pending — route not deployed) instead when the sale stands but money (all or some lines) goes back to the guest.","response":{"description":"{ status, message }. Success: HTTP 200 { status:true, message:'Order canceled successfully' }. Errors: 404 { status:false, message:'Invoice Not Found' }; 500 { status:false, message, error } where message/error is 'INVOICE_ALREADY_CANCELLED' (called twice), 'INVOICE_CUSTOMER_MISMATCH' (order of another tenant) or a DB/inventory error — on any 500 nothing was changed (transaction rolled back).","fields":{"status":"boolean. true = order cancelled. false = see message.","message":"string. 'Order canceled successfully' | 'Invoice Not Found' | 'INVOICE_ALREADY_CANCELLED' | 'INVOICE_CUSTOMER_MISMATCH' | other error text.","error":"string. Only on HTTP 500; same text as message."},"master_rules":["Get the uuid and status first (list_orders_history, list_dine_orders or get_invoice). web-pos offers Cancel only for status open / opened / completed / partially_refunded; do not cancel 'cancelled' or 'refunded' orders (the server only refuses 'cancelled').","web-pos asks for the 4-digit business PIN (user extras business_config.pincode) before cancelling when one is set. An agent cannot enter it: get explicit confirmation from the user (manager) before calling, and ask for the reason and the disposition (wastage or return to stock).","Body as web-pos builds it: { note: reason, re: reason, is_wastage: 1|0, ri: 0|1, close_by_user_id, brand_id }. Default disposition = wastage.","What changes: invoices.status = 'cancelled' (+ is_wastage = 1 for wastage); every line gets item_status 'disposition' with the reason; for master-inventory users the stock is released (open order) or returned / written off (completed order); one withdrawal (cash-out) row per payment of the order, description 'refund/<order no>'; wallet-paid orders get the wallet payment reversed; product-quota units are given back (list_product_quota sold_count goes down; best effort, errors ignored); crm_users purchase totals are recomputed; Grubtech / Limetray / Kiply get a cancel.","NOT reversed: loyalty points earned or burned (validate_loyalty_burn), coupon usage (list_selling_coupons), payments rows (they stay; the withdrawal offsets them).","KDS: the order leaves list_kds_orders on the next poll. web-pos also publishes a Firestore 'cancelled' signal so other tills update at once; MCP does not, so other tills see it on their next refresh.","Idempotency: a second call returns 500 INVOICE_ALREADY_CANCELLED and changes nothing. There is no un-cancel.","After cancelling a dine order the table is free again (list_dine_orders no longer returns it)."],"caveats":"No time limit and no status check except 'already cancelled': refunded orders can still be cancelled, which books the withdrawals again. The per-line `items[]` form (iOS: [{ identifier, re, ri, is_wastage }]) and query aliases ?re= / ?ri= / ?type=wastage|return exist but are not exposed; web-pos does not use them.","example":{"status":true,"message":"Order canceled successfully"}},"parameters":{"type":"object","properties":{"uuid":{"type":"string","description":"Required."},"note":{"type":"string","description":"Optional."},"re":{"type":"string","description":"Optional."},"is_wastage":{"type":"number","description":"Stock disposition."},"ri":{"type":"number","description":"Stock disposition."},"close_by_user_id":{"type":"number","description":"Optional."},"brand_id":{"type":"number","description":"Optional."}},"required":["uuid"]},"module":"orders","safety":"write"},{"name":"refund_invoice","description":"POST /api/v2/orders-refund. Why: LEGACY money-only full refund (the iOS route; web-pos does not call it). is_refund=1 marks the whole invoice refunded (invoices.is_refunded = 1 — status stays 'completed') and adds ONE negative payment of the full payable; undo_refund=1 reverses exactly that. No stock, lines, quota, wallet, loyalty or KDS change. Use it only when the user wants the money recorded as refunded without touching stock, or to undo a refund made by this tool. For web-pos parity (status 'refunded'/'partially_refunded', per-line refunds, stock returned or wasted) use refund_pos_invoice (pending — route not deployed). Never use both tools on the same order. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"refund_invoice\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/v2/orders-refund","why":"LEGACY money-only full refund (the iOS route; web-pos does not call it). is_refund=1 marks the whole invoice refunded (invoices.is_refunded = 1 — status stays 'completed') and adds ONE negative payment of the full payable; undo_refund=1 reverses exactly that. No stock, lines, quota, wallet, loyalty or KDS change. Use it only when the user wants the money recorded as refunded without touching stock, or to undo a refund made by this tool. For web-pos parity (status 'refunded'/'partially_refunded', per-line refunds, stock returned or wasted) use refund_pos_invoice (pending — route not deployed). Never use both tools on the same order.","response":{"description":"Always HTTP 200 { status, message } — read status. true: 'Invoice refunded successfully' | 'Invoice undo refund successful'. false: 'Invoice already refunded' | 'Invoice is not refunded' | 'Valid order UUID is required' | 'Refund items not available.' (is_refund/undo_refund missing or not the number 1) | 'An error occurred during refund operation'.","fields":{"status":"boolean. true = refund (or undo) recorded.","message":"string. See the list above."},"master_rules":["Full refund only. The item-level refund_items form of this route cannot be reached while order_uuid is sent (the full-refund branch runs first), so partial refunds must use refund_pos_invoice.","Precondition: a paid order (list_orders_history status 'completed', is_paid 1). The server does not check status or is_paid — it will 'refund' open or cancelled orders too. Confirm the order and amount with the user first.","What it writes: invoices.is_refunded = 1 (undo: 0) and one payments row { payment_method: refund_payment_method lower-cased, amount: −payable (undo: +payable), is_payment_refunded: 1 }. invoices.status is NOT changed, so list_orders_history still shows 'completed'.","Nothing else: no stock return or wastage, no invoice_items change, no product-quota give-back (list_product_quota), no wallet, loyalty (validate_loyalty_burn) or coupon (list_selling_coupons) reversal, no KDS / Firestore signal, no withdrawal row.","Idempotent per direction: a second is_refund=1 returns 'Invoice already refunded'; undo only works on a refunded invoice.","Do not mix with refund_pos_invoice: that tool does not see is_refunded from this one, so both would refund the money twice. After a refund_pos_invoice refund this tool answers 'Invoice already refunded'.","Do not call update_invoice_payment_methods after refunding: it deletes ALL payments rows of the order, including this refund row."],"caveats":"The refund payment row is saved without customer_id / location_id (and payments.user holds the tenant id), so branch or shift payment reports may not count it.","example":{"status":true,"message":"Invoice refunded successfully"}},"parameters":{"type":"object","properties":{"order_uuid":{"type":"string","description":"Required."},"is_refund":{"type":"number","description":"Send 1 to refund the whole invoice."},"undo_refund":{"type":"number","description":"Send 1 to undo a full refund previously made with is_refund=1 (only when is_refund is not sent)."},"refund_payment_method":{"type":"string","description":"Optional."}},"required":["order_uuid"]},"module":"orders","safety":"write"},{"name":"update_invoice_payment_methods","description":"PUT /api/v2/invoice/:uuid/payment-methods. Why: Re-book the tender of a PAID order (web-pos 'Change Payment Method' in order history, e.g. the guest paid by card, not cash). REPLACES all payments rows of the order with payment_methods and rewrites invoices.payment_method / paid / change / tip_amount / card_digits (and shift_id). The order total, lines and status do not change. Method names come from list_selling_payment_methods. Logged as an order activity 'Payment Method Updated' (get_invoice_activities). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_invoice_payment_methods\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/v2/invoice/:uuid/payment-methods","why":"Re-book the tender of a PAID order (web-pos 'Change Payment Method' in order history, e.g. the guest paid by card, not cash). REPLACES all payments rows of the order with payment_methods and rewrites invoices.payment_method / paid / change / tip_amount / card_digits (and shift_id). The order total, lines and status do not change. Method names come from list_selling_payment_methods. Logged as an order activity 'Payment Method Updated' (get_invoice_activities).","response":{"description":"Success HTTP 200 { status:true, http_status:200, message:'Payment method updated successfully', data }. Errors { status:false, http_status, message }: 400 'Validation error' (+ errors[] of Joi messages), 400 'Cannot update payment method for cancelled|refunded invoices', 400 'Cannot update payment method for an open unpaid invoice. Close and pay the order first.', 400 unknown method name, 400 paid < payable, 403 invoice of another tenant, 404 'Invoice not found', 500 'Failed to update payment method' (+ error; nothing changed).","fields":{"status":"boolean. true = tender replaced.","http_status":"int. Same as the HTTP status.","message":"string. 'Payment method updated successfully' or the error text.","errors":"string[]. Only on 'Validation error': Joi messages.","error":"string. Only on 500 ('Internal server error' outside development).","data.invoice_id":"int. Invoice row id.","data.invoice_uuid":"string. Invoice uuid.","data.customer_invoice_id":"int. Order number shown to staff.","data.invoice_status":"string. Order status (unchanged; 'completed' / 'partially_refunded' / 'open').","data.shift_id":"int | null. Shift the order is now booked under.","data.payment_methods":"array. The tender list saved (as sent).","data.previous_payment_methods":"array. Payment rows before the change: { payment_method, payment_method_id, amount, tip_amount }.","data.change_rows":"array. Money moved between methods: { change: 'cash → card', from_payment_method, to_payment_method, from_is_cash, to_is_cash, amount, cash_impact }. [] when nothing moved.","data.cash_before":"number. Cash-type total before.","data.cash_after":"number. Cash-type total after.","data.cash_impact":"number. cash_after − cash_before (negative = less cash expected in the drawer).","data.reason":"string | null. Reason as sent (trimmed).","data.paid":"string (2 dp). Saved paid.","data.change":"string (2 dp). Saved change.","data.tip_amount":"string (2 dp). Saved tip.","data.total_amount":"string (2 dp). Σ payment_methods amounts.","data.invoice_payable":"string (2 dp). Order total the tender must cover.","data.card_digits":"string | null. Saved card digits."},"master_rules":["Precondition: a paid order — list_orders_history status 'completed' or 'partially_refunded' (web-pos offers the button only for these, and only when the setting is_change_payment_method_enabled = 1 for the user/app). Get the current payment_method, paid, change, tip_amount from list_orders_history / get_invoice.","Body as web-pos builds it: { payment_methods: [{ \"<method name lower-case>\": { amount: paid − change − tip, tip_amount: tip } }], paid, change, tip_amount } — one method carrying the whole amount. Resend paid, change and tip_amount unchanged.","Method keys = list_selling_payment_methods [].name (is_enable = 1). The server checks the name exists for the session branch (case-insensitive) but not is_enable.","It REPLACES all payments rows of the order (DELETE + insert). Refund rows written by refund_invoice / refund_pos_invoice are deleted too — do not use this on a refunded or partially refunded order unless the user accepts losing the refund record.","Side effects: order activity 'Payment Method Updated' (get_invoice_activities), change log, and an invoice push notification. No stock, quota, loyalty or KDS change.","Idempotent: sending the same tender again rewrites the same rows (a new activity entry is logged each time).","web-pos asks no PIN here yet (Android does); confirm the change with the user before calling."],"caveats":"Legacy body aliases are also accepted but not exposed: payment_method (array like payment_methods, or a string name + amount), paymentMethods / paymentMethod / tipAmount / cardDigits / shiftId. The server does not check that Σ amounts equals payable — only paid ≥ payable — so keep them consistent.","example":{"status":true,"http_status":200,"message":"Payment method updated successfully","data":{"invoice_id":9412231,"invoice_uuid":"0b7c2f4e-3a91-4d2e-9c55-7e1f0a6b8d21","customer_invoice_id":1187,"invoice_status":"completed","shift_id":null,"payment_methods":[{"card":{"amount":11,"tip_amount":0}}],"previous_payment_methods":[{"payment_method":"Cash","payment_method_id":1631,"amount":11,"tip_amount":0}],"change_rows":[{"change":"Cash → card","from_payment_method":"Cash","to_payment_method":"card","from_is_cash":true,"to_is_cash":false,"amount":11,"cash_impact":-11}],"cash_before":11,"cash_after":0,"cash_impact":-11,"reason":null,"paid":"11.00","change":"0.00","tip_amount":"0.00","total_amount":"11.00","invoice_payable":"11.00","card_digits":null}}},"parameters":{"type":"object","properties":{"uuid":{"type":"string","description":"Required."},"payment_methods":{"type":"array","items":{"type":"string","description":"{ \"<method name>\": amount | { amount, tip_amount } }"},"description":"Required."},"paid":{"type":"number","description":"Optional."},"change":{"type":"number","description":"Optional."},"tip_amount":{"type":"number","description":"Optional."},"card_digits":{"type":"string","description":"Optional."},"shift_id":{"type":"number","description":"Optional."},"reason":{"type":"string","description":"Optional."}},"required":["uuid","payment_methods"]},"module":"orders","safety":"write"},{"name":"log_invoice_activities","description":"POST /api/v2/activities. Why: Write ONE audit row to an order's Activity trail — the batch activity writer web-pos uses (the server never logs cashier actions itself, except update_invoice_payment_methods). Log what you did through other tools so the trail matches a web-pos till: e.g. after cancel_invoice title \"Void\" message \"Cancelled order reason: X\"; after sync_invoice paid title \"Paid\" message \"paid [30] using [CASH]\"; title \"Order Created\" for a new order. Read back with get_invoice_activities (same uuid). Skip it when get_user_extras ios_modules.is_activities_enabled = 0. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"log_invoice_activities\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/v2/activities","why":"Write ONE audit row to an order's Activity trail — the batch activity writer web-pos uses (the server never logs cashier actions itself, except update_invoice_payment_methods). Log what you did through other tools so the trail matches a web-pos till: e.g. after cancel_invoice title \"Void\" message \"Cancelled order reason: X\"; after sync_invoice paid title \"Paid\" message \"paid [30] using [CASH]\"; title \"Order Created\" for a new order. Read back with get_invoice_activities (same uuid). Skip it when get_user_extras ios_modules.is_activities_enabled = 0.","response":{"description":"HTTP 201 { status: true, message: \"Activities have been successfully saved.\", data: rows[] } — the saved row(s) as sent (placeholder id \"0\" removed) plus a synthetic id. Stored in MongoDB, not MySQL. Errors: 422 { status:false, message:\"Either payload is empty or not an array.\", data:null } (MCP always sends a one-element array); 500 { status:false, data:null } on a database error (e.g. the same identifier twice in one batch).","fields":{"status":"boolean. true = saved.","message":"string. \"Activities have been successfully saved.\"","data":"array. The one row you sent, as stored.","data[].id":"int. SYNTHETIC: request time in ms + index. Not an id; do not store it.","data[].identifier":"string. Your identifier (the real key).","data[].uuid":"string. Order uuid.","data[].title":"string. Title.","data[].message":"string. Message (when sent).","data[].user_id":"int. User id (when sent).","data[].username":"string. User name (when sent).","data[].role":"string. Role (when sent).","data[].timestamp":"int. Unix seconds (when sent).","data[].platform":"string. Platform (when sent).","data[].device_model":"string. Device text (when sent).","data[].device_id":"string. Device id (when sent).","data[].app_version":"string. App version (when sent).","data[].location_id":"int. Branch id.","data[].session_id":"string. Session id (when sent).","data[].order_detail":"object. Order snapshot (when sent)."},"master_rules":["web-pos writer (services/activityLog.js): every cashier action queues one entry { identifier: new UUID, id: \"0\", user_id, username, role, timestamp (unix s, monotonic), title, message, platform: \"web\", device_model, app_version, uuid (order uuid → draft order uuid → per-login session uuid), location_id, customer_id, device_id, session_id, order_detail (draft snapshot or null) } in localStorage.","Upload: debounced 1.5 s after the last entry (immediately at 50 pending); POST a JSON ARRAY of up to 100 entries per request; on page hide a keepalive flush of ≤ 15 entries WITHOUT order_detail (64 KB browser limit); also 3 s after load and before the Activity section loads. A 2xx whose body is not status:false acknowledges the batch (entries removed by identifier); failures stay queued. Logout clears the queue.","Idempotency: identifier is a unique key. Same identifier again → the stored row is updated (no duplicate), so retry with the SAME identifier after a timeout. MCP sends one entry per call (no batching).","Gate: web-pos logs nothing when get_user_extras ios_modules.is_activities_enabled = 0 (tenant flag); do the same.","web-pos posts to NEXT_PUBLIC_TRACK_API_URL (default app.syspos.ae), not always the API host MCP talks to — a test server may lack web-pos rows.","The server stores the body verbatim: nothing is validated or stamped from the session (no server user / time)."],"caveats":"MCP never forwards customer_id, so rows written through this tool have no customer_id: they show in get_invoice_activities (matched by uuid only) but not in tenant-wide activity reports that filter by customer_id. timestamp must be unix seconds (get_invoice_activities also sees ISO strings from server-written rows).","example":{"request":{"identifier":"5b0f1c2e-8a4d-4b7e-9d3a-2f6c1e0a9b71","uuid":"f5bd46f3-377a-477d-b748-876e9aab0063","title":"Void","message":"Cancelled order reason: wrong item","user_id":4734,"username":"Cashier One","role":"admin","timestamp":1790257026,"platform":"mcp","device_model":"MCP agent","location_id":907,"session_id":""},"response":{"status":true,"message":"Activities have been successfully saved.","data":[{"identifier":"5b0f1c2e-8a4d-4b7e-9d3a-2f6c1e0a9b71","uuid":"f5bd46f3-377a-477d-b748-876e9aab0063","title":"Void","message":"Cancelled order reason: wrong item","user_id":4734,"username":"Cashier One","role":"admin","timestamp":1790257026,"platform":"mcp","device_model":"MCP agent","location_id":907,"session_id":"","id":1790257026487}]},"note":"Code-derived (write route not called live)."}},"parameters":{"type":"object","properties":{"identifier":{"type":"string","description":"Required (by this tool)."},"uuid":{"type":"string","description":"Required."},"title":{"type":"string","description":"Required."},"message":{"type":"string","description":"Optional."},"user_id":{"type":"number","description":"Optional."},"username":{"type":"string","description":"Optional."},"role":{"type":"string","description":"Optional."},"timestamp":{"type":"number","description":"Optional but send it."},"platform":{"type":"string","description":"Optional."},"device_model":{"type":"string","description":"Optional."},"device_id":{"type":"string","description":"Optional."},"app_version":{"type":"string","description":"Optional."},"location_id":{"type":"number","description":"Optional."},"session_id":{"type":"string","description":"Optional."},"order_detail":{"type":"string","description":"Optional."}},"required":["identifier","uuid","title"]},"module":"orders","safety":"write"},{"name":"list_kds_orders","description":"GET /api/v2/kds-orders. Why: The kitchen board — what web-pos's KDS screen polls every 4 s: today's orders of the session branch that are not bumped and not cancelled, each with its lines (is_prepared = ticked) and accept state (is_accepted). Use it to see what the kitchen still has to make, per station (c = category name), and to get uuid / order_items[].id for the three KDS write tools. is_order_rebump=1 returns the bumped orders instead (recall list). Not paginated. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_kds_orders\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/kds-orders","why":"The kitchen board — what web-pos's KDS screen polls every 4 s: today's orders of the session branch that are not bumped and not cancelled, each with its lines (is_prepared = ticked) and accept state (is_accepted). Use it to see what the kitchen still has to make, per station (c = category name), and to get uuid / order_items[].id for the three KDS write tools. is_order_rebump=1 returns the bumped orders instead (recall list). Not paginated.","response":{"description":"A BARE JSON ARRAY of orders (no status/data envelope), sorted by order_no ascending, each with order_items[]. [] when nothing is open. Live dev1 (tenant 437, branch 907) returned [] at the time of writing, so the example is code-derived (fake data). On a DB error the request fails (HTTP 500 / no body).","fields":{"[]":"array. KDS tickets = today's not-bumped, not-cancelled orders of the session branch, oldest order_no first. [] = nothing to cook.","[].id":"string. The LAST 12 CHARACTERS of uuid — NOT a database id. Never send it to another tool; send uuid.","[].oid":"int. Same as order_no (the list is sorted by it). web-pos shows it as the ticket number '#<oid>'.","[].order_no":"int. SHORT ORDER NUMBER shown on the ticket. The counter restarts, so it is NOT unique — never use it as a key.","[].customer_invoice_id":"int. Invoice number (per tenant). NOT shown on the KDS.","[].token_number":"int | null. Queue token. web-pos shows 'TOKEN <n>' in the header when the order has no table.","[].is_accepted":"0 | 1 | 2 | null. THE KDS ACCEPT STATE (update_kds_order_status). 1 = accepted (cooking). 2 = REJECTED: web-pos shows the ticket greyed with a CANCELLED banner until someone dismisses it (change_kds_order_status). 0 or null = PENDING: web-pos shows ACCEPT / REJECT instead of the bump button and item lines cannot be ticked (web-pos tests Number(is_accepted) === 0, and Number(null) is 0).","[].external_order_ready":"0 | 1. Aggregator 'ready' flag written by another route. NOT USED by the KDS.","[].exchange_rate":"number | null. NOT USED.","[].is_wastage":"0 | 1. Cancelled as wastage. NOT USED (cancelled orders are not listed).","[].paid_in_usd":"number | null. NOT USED.","[].is_discount_on_total":"0 | 1 | null. NOT USED.","[].items_number":"int. Number of lines when the order was saved. NOT USED (count order_items yourself).","[].aggregator_order_id":"string | null. Order id at the delivery aggregator. Makes the ticket ONLINE; shown as the delivery reference (first non-zero of aggregator_order_id, external_order_id, plate_number).","[].subtotal":"number. NOT USED by the KDS (money).","[].vat":"number. Tax (out of scope). NOT USED.","[].channel_id":"int. Sales channel id (list_selling_channels). NOT USED by the KDS.","[].payable":"number. Order total. NOT USED by the KDS.","[].discountamount":"number. NOT USED by the KDS.","[].items_discount_amount":"number. Sum of line discounts, recomputed by the server. NOT USED by the KDS.","[].is_ticket_available":"0 | 1. NOT USED.","[].is_wallet_used":"0 | 1. NOT USED by the KDS.","[].discountpct":"number. NOT USED by the KDS.","[].paid":"number. NOT USED by the KDS. The KDS shows paid and unpaid orders alike.","[].change":"number. NOT USED by the KDS.","[].note":"string | null. ORDER NOTE, shown on the ticket; if it contains 'allerg' (any case) web-pos flags the ticket as ALLERGY (red).","[].uuid":"string. THE ORDER KEY. Pass it to update_kds_order_status (uuid) and change_kds_order_status (path uuid); same uuid as list_orders_history / list_dine_orders / get_invoice / cancel_invoice.","[].created_at":"int. Unix seconds (UTC) the order was created. Starts the ticket timer (warn at half the late threshold, late at the threshold; web-pos default 2 min).","[].updated_at":"int. Unix seconds of the last change of the invoice row (a bump, recall or accept/reject also moves it). In the recall list (is_order_rebump=1) it is ~ the bump time.","[].customer_id":"int. NOT USED. Tenant id.","[].location_id":"int. Branch of the order = the session branch.","[].row_discount":"0 | 1. NOT USED by the KDS.","[].order_notes":"string | null. Raw JSON text of the notes list. NOT USED — read notes instead.","[].info_name":"string | null. Customer name sent with the order (aggregator info block). First choice for the ticket's customer name, then crm_user_name.","[].info_address":"string | null. Delivery address. NOT USED by the KDS.","[].info_phone":"string | null. NOT USED by the KDS.","[].info_email":"string | null. NOT USED by the KDS.","[].status":"string. Invoice status: 'open' (saved, unpaid), 'completed' (paid), 'pending_acceptance' (online order waiting for the branch), 'refunded' / 'partially_refunded' also stay listed. Never 'cancelled' / 'canceled' / 'cancel' (filtered out). The KDS does NOT change it, except accepting a 'pending_acceptance' order (→ 'completed').","[].by":"string | null. Source of the order. Empty, 'pos', 'web', 'ios', 'android' or 'web-pos' = POS; anything else (e.g. 'shopify', 'talabat') = external → ONLINE ticket, label shown capitalised.","[].external_order_id":"string | null. invoices.order_id (external order id). Non-zero makes the ticket ONLINE; used as delivery reference.","[].burn_points":"number. NOT USED by the KDS.","[].earn_points":"number. NOT USED by the KDS.","[].burn_points_amount":"number. NOT USED by the KDS.","[].available_points":"number. NOT USED.","[].available_points_amount":"number. NOT USED.","[].charges_fee":"number | null. NOT USED by the KDS.","[].delivery_charges":"number | null. NOT USED by the KDS.","[].phone_number":"string | null. NOT USED by the KDS.","[].loyalty_mobile_number":"string | null. NOT USED.","[].loyalty_mobile_code":"string | null. NOT USED.","[].crm_delivery_area":"string | null. NOT USED.","[].user":"string. Login of the POS user whose session saved the order. NOT USED by the KDS.","[].table_id":"int | null. Table of a dine-in order (list_selling_tables id). web-pos shows 'TABLE <table name>' in the header (name looked up locally; falls back to the id). 0/null = no table.","[].plate_number":"string | null. Car plate (drive-through). Shown as the reference when there is no aggregator/external id.","[].is_vat_on_discount_exclude":"0 | 1. Tax setting (out of scope). NOT USED.","[].created_by_user_id":"int | null. NOT USED by the KDS.","[].close_by_user_id":"int | null. NOT USED by the KDS.","[].modified_by_user_ids":"int[]. Users who edited the order ([] when none). NOT USED by the KDS.","[].crm_user_id":"int. Customer id (list_pos_crm_users). NOT USED by the KDS.","[].crm_user_name":"string | null. Customer name; shown on the ticket when info_name is empty.","[].order_type":"string | null. Order type TITLE as saved (list_selling_order_types title). Contains 'dine'/'dining' (case-insensitive) → DINE-IN ticket; exact 'online' → ONLINE; otherwise shown as the source label (POS when empty).","[].tip_amount":"number. NOT USED by the KDS.","[].brand_id":"int | null. Brand of the order (list_selling_brands). Filter with the brand_id param.","[].dining_guests":"int | null. NOT USED by the KDS.","[].channel_name":"string | null. NOT USED by the KDS.","[].discount_reason":"string | null. NOT USED by the KDS.","[].paid_at":"int | null. Unix seconds of payment. NOT USED by the KDS.","[].coupon_amount":"number. NOT USED by the KDS.","[].coupon_id":"int. NOT USED by the KDS.","[].coupon_code":"string | null. NOT USED by the KDS.","[].printed_invoice_receipts":"int. NOT USED.","[].printed_kds_receipts":"int. Kitchen print count. NOT USED (the KDS print button prints locally and does not update it).","[].app_version":"string | null. NOT USED.","[].app_os_version":"string | null. NOT USED.","[].build_number":"string | null. NOT USED.","[].os_type":"string | null. NOT USED.","[].round":"int | null. Order's current dine-in round (course). NOT USED — the ticket groups lines by order_items[].round.","[].rider_pickup_at":"int | null. Unix seconds the delivery rider picked up. NOT USED.","[].rider_arrived_at":"int | null. Unix seconds the rider arrived. NOT USED.","[].order_type_icon_key":"string | null. Iconify key of the order type icon. NOT USED by the KDS.","[].order_type_icon":"string | null. PNG URL of that icon. NOT USED by the KDS.","[].notes":"string[]. Parsed order notes list ([] = none). NOT shown by the KDS (it shows note).","[].payment_method":"array | null. [{ '<method name>': amount }] per payment row; null = unpaid. NOT USED by the KDS.","[].payment_methods":"array | null. Same rows with tips: [{ '<method name>': { amount, tip_amount } }]. NOT USED by the KDS.","[].payments":"array | null. Same rows flat: [{ payment_method, amount, tip_amount }]. NOT USED by the KDS.","[].order_items":"array. ALL lines of the order (top-level lines; combo children are inside combo_items). Includes removed lines (is_deleted 1) and add-on rows (is_addon 1) — web-pos hides both.","[].order_items[].id":"int. LINE ID (invoice_items.id). Pass it as item_id to update_kds_item_status.","[].order_items[].identifier":"string. Line uuid. Add-on rows point to it with addon_linked_with.","[].order_items[].name":"string. Line name as sold. Shown with qty.","[].order_items[].name_localize":"string | null. Localized name. NOT USED by the KDS.","[].order_items[].actual_price":"number. NOT USED by the KDS (money).","[].order_items[].base_price":"number | null. NOT USED by the KDS.","[].order_items[].price":"number. NOT USED by the KDS.","[].order_items[].is_weightage_item":"0 | 1 | null. Product sold by weight (qty is a weight). NOT USED.","[].order_items[].unit_id":"int | null. Unit of the weighted product. NOT USED.","[].order_items[].qty":"number. Quantity to cook. Shown.","[].order_items[].subtotal":"number. NOT USED by the KDS.","[].order_items[].line_total":"number | null. NOT USED.","[].order_items[].line_vat":"number | null. Tax (out of scope). NOT USED.","[].order_items[].line_total_without_vat":"number | null. NOT USED.","[].order_items[].invoice_uuid":"string. uuid of the order.","[].order_items[].product_id":"int. Product (list_selling_products).","[].order_items[].extras":"array | object | null. Priced extras [{ name, … }]; names are shown under the line with the modifiers. Old rows may hold a { materialId: qty } map.","[].order_items[].vat":"number. Tax (out of scope). NOT USED.","[].order_items[].is_updated":"0 | 1. Quantity changed / line added on a later save. NOT USED by web-pos (it detects changes itself by polling).","[].order_items[].updated_qty":"int. The changed quantity. NOT USED.","[].order_items[].is_addon":"0 | 1. 1 = ADD-ON ROW linked to a main line (addon_linked_with). web-pos does NOT show add-on rows on the KDS.","[].order_items[].is_refunded":"0 | 1. NOT USED by the KDS.","[].order_items[].refund_quantity":"int. NOT USED by the KDS.","[].order_items[].refund_reason":"string | null. NOT USED by the KDS.","[].order_items[].note":"string | null. LINE NOTE. Shown under the line (orange).","[].order_items[].addon_linked_with":"string | null. On an add-on row: identifier of its main line.","[].order_items[].discount_pct":"number. NOT USED by the KDS.","[].order_items[].discount_amount":"number. NOT USED by the KDS.","[].order_items[].modifier":"array. Chosen modifier options (list_selling_modifiers) — [{ name, … }] or grouped { name, items:[{ name }] }. Option names are shown under the line; 'no …' / allergy options in red. [] = none.","[].order_items[].transferred_to":"int. customer_invoice_id the line was moved to. 0 = not transferred. NOT USED by the KDS.","[].order_items[].transferred_to_uuid":"string | null. NOT USED by the KDS.","[].order_items[].transferred_to_uuid_list":"string[]. NOT USED by the KDS.","[].order_items[].transferred_quantities":"number[]. NOT USED by the KDS.","[].order_items[].transfer_recipients":"int[]. NOT USED by the KDS.","[].order_items[].created_at":"int. Unix seconds the line was added. Starts the timer of its round when the order has several rounds.","[].order_items[].updated_at":"int. Unix seconds of the last change of the line.","[].order_items[].is_prepared":"0 | 1 | null. THE KITCHEN TICK. 1 = prepared (line struck through). 0/null = to do. Set with update_kds_item_status. When every visible line is 1 the ticket is ready to bump.","[].order_items[].preparation_time":"int. Minutes, from the product. NOT USED by the KDS.","[].order_items[].type":"string | null. THE STATION: products.type = the product's category name (list_selling_categories name; several names comma-joined when the product has several categories). The c filter matches it exactly.","[].order_items[].label_id":"int | null. Kitchen label (list_labels). NOT USED by the KDS.","[].order_items[].is_combo":"0 | 1 | 2 | null. 1/2 = combo line; its children are in combo_items (NOT shown by web-pos KDS).","[].order_items[].is_deleted":"0 | 1 | null. 1 = REMOVED from the order. web-pos hides it; do not cook it.","[].order_items[].deleted_at":"int | null. Unix seconds of the removal.","[].order_items[].deleted_qty":"number | null. Quantity removed.","[].order_items[].coupon_amount":"number. NOT USED by the KDS.","[].order_items[].coupon_id":"int. NOT USED by the KDS.","[].order_items[].coupon_code":"string | null. NOT USED by the KDS.","[].order_items[].updated_by_user_ids":"int[]. NOT USED by the KDS.","[].order_items[].re":"string | null. Cancel/return reason on the line. NOT USED by the KDS.","[].order_items[].ri":"0 | 1 | null. NOT USED by the KDS.","[].order_items[].is_wastage":"0 | 1 | null. NOT USED by the KDS.","[].order_items[].discount_id":"int | null. NOT USED by the KDS.","[].order_items[].discount_name":"string | null. NOT USED by the KDS.","[].order_items[].round":"int | null. Dine-in round in which the line was added (1-based). When lines have more than one distinct round, web-pos groups them by round with a separator and a timer per round.","[].order_items[].combo_items":"array | null. Children of a combo line (is_combo 1/2); null otherwise. Each child has the same fields as order_items[] except is_weightage_item, unit_id, is_combo, is_deleted, deleted_at, deleted_qty and combo_items; its id is a line id too (update_kds_item_status accepts it, but web-pos does not tick children)."},"master_rules":["Which orders: the session branch only (the server uses the login's branch, there is no location param), created in the current business day (local time zone of the tenant config; before the opening_hour the window starts at yesterday's opening_hour, after it at today 00:00), status not cancelled, and done_at empty (not bumped). Paid and unpaid orders are both listed. is_order_rebump=1 lists the bumped ones instead (the recall list).","Station filter: web-pos builds the chips from its cached categories (list_selling_categories names) plus 'All', and sends c=<category name> (omitted for All). The server keeps orders that have at least one line whose product type equals c exactly, but returns ALL lines of those orders — filter order_items[].type yourself to show one station's work.","Visible lines (web-pos): drop is_deleted = 1 and is_addon = 1. Each line shows qty, name, modifier + extras names and note; is_prepared = 1 is struck through.","Ticket type (web-pos): order_type contains 'dine'/'dining' → DINE-IN (blue); else external (order_type 'online', a non-POS by, aggregator_order_id or a non-zero external_order_id) → ONLINE (green); else DRIVE (purple). Header colour follows elapsed time from created_at: green, orange at half the late threshold, red at the threshold (default 2 min). Rejected (is_accepted 2) tickets are grey with a CANCELLED banner.","Status model (the same one the server publishes to Ably): is_accepted 2 → cancelled/rejected; done_at set → ready (bumped); is_accepted 1 → preparing; otherwise pending. Items: is_prepared 0/null → to do, 1 → prepared. Invoice status is a separate thing (open/completed = payment), except that accepting a 'pending_acceptance' order completes it.","Kitchen flow (web-pos buttons): pending ticket → ACCEPT = update_kds_order_status status=1, REJECT = status=2. Tap a line → update_kds_item_status item_id=<order_items[].id> is_prepared=1 (tap again → 0); blocked while the ticket is pending or rejected. BUMP / DONE → change_kds_order_status uuid (the ticket leaves this list). UNDO (8 s) or RECALL from the recall list → change_kds_order_status uuid is_order_rebump=1. Dismissing a rejected ticket is also a bump. Optional auto-bump (device setting, default off) bumps 0.7 s after every visible line is prepared.","Refresh: web-pos polls this list every 4 s (plus on focus / reconnect / Firestore order signals). Call it again after any change; there is no push to MCP.","Orders get here via sync_invoice (web-pos Pay / Save order). Each line's station is the product's category, not something sync_invoice sends. When you UPDATE an open order with sync_invoice, resend each line's is_prepared (it is overwritten, so omitted = kitchen ticks lost) and keep is_accepted in mind: web-pos saves new POS orders with is_accepted 0, and an MCP sync_invoice leaves it null — both make the ticket PENDING on the web-pos KDS until update_kds_order_status status=1.","The same data is visible from the order side: list_orders_history / list_dine_orders show items[].is_prepared and done_at for the same uuid; get_invoice gives the full order. cancel_invoice removes the order from this list on the next poll (status 'cancelled'); a KDS reject does NOT cancel the sale.","Kitchen printing is local to the web-pos device (Print button, and auto-print of new ONLINE orders when the device is a KDS device with 'external order print receipt' on). No API call and printed_kds_receipts is not updated — an agent cannot print."],"caveats":"No location param: MCP cannot pick the branch — it is the branch of the API key's login. done_at is NOT in the response (the board = not bumped; the recall list = bumped). guest_name is not selected either (use info_name / crm_user_name). The business-day window uses the opening_hour config; orders created before it (e.g. yesterday evening after midnight rollover) may drop off. Also read but not exposed: app_version / build_number (qty int/float formatting for old iOS apps) and icon_pixel_size / icon_format (order_type_icon URL).","example":[{"id":"a0b1c2d3e4f5","oid":57,"order_no":57,"customer_invoice_id":10433,"token_number":null,"is_accepted":1,"external_order_ready":0,"exchange_rate":null,"is_wastage":0,"paid_in_usd":null,"is_discount_on_total":0,"items_number":2,"aggregator_order_id":null,"subtotal":48,"vat":0,"channel_id":0,"payable":48,"discountamount":0,"items_discount_amount":0,"is_ticket_available":0,"is_wallet_used":0,"discountpct":0,"paid":0,"change":0,"note":"Guest allergic to sesame","uuid":"c1d2e3f4-5a6b-4c7d-8e9f-a0b1c2d3e4f5","created_at":1790227800,"updated_at":1790227890,"customer_id":437,"location_id":907,"row_discount":0,"order_notes":null,"info_name":null,"info_address":null,"info_phone":null,"info_email":null,"status":"open","by":"web-pos","external_order_id":null,"burn_points":0,"earn_points":0,"burn_points_amount":0,"available_points":0,"available_points_amount":0,"charges_fee":0,"delivery_charges":0,"phone_number":"","loyalty_mobile_number":"","loyalty_mobile_code":"","crm_delivery_area":null,"user":"cashier@example.com","table_id":1204,"plate_number":null,"is_vat_on_discount_exclude":0,"created_by_user_id":3101,"close_by_user_id":null,"modified_by_user_ids":[],"crm_user_id":0,"crm_user_name":"","order_type":"Dine In","tip_amount":0,"brand_id":null,"dining_guests":2,"channel_name":"","discount_reason":null,"paid_at":null,"coupon_amount":0,"coupon_id":0,"coupon_code":null,"printed_invoice_receipts":0,"printed_kds_receipts":0,"app_version":null,"app_os_version":null,"build_number":null,"os_type":null,"round":1,"rider_pickup_at":null,"rider_arrived_at":null,"order_type_icon_key":"mdi:silverware-fork-knife","notes":[],"payment_method":null,"payment_methods":null,"payments":null,"order_items":[{"id":1583204,"identifier":"8f7c2a10-3b1e-4c55-9a7e-0d4b6f1e2a31","name":"Chicken Shawarma","name_localize":null,"actual_price":18,"base_price":18,"price":18,"is_weightage_item":0,"unit_id":null,"qty":2,"subtotal":36,"line_total":null,"line_vat":null,"line_total_without_vat":null,"invoice_uuid":"c1d2e3f4-5a6b-4c7d-8e9f-a0b1c2d3e4f5","product_id":52110,"extras":null,"vat":0,"is_updated":0,"updated_qty":0,"is_addon":0,"is_refunded":0,"refund_quantity":0,"refund_reason":null,"note":"no pickles","addon_linked_with":null,"discount_pct":0,"discount_amount":0,"modifier":[{"id":881,"name":"Spicy","price":0,"qty":1}],"transferred_to":0,"transferred_to_uuid":null,"transferred_to_uuid_list":[],"transferred_quantities":[],"transfer_recipients":[],"created_at":1790227800,"updated_at":1790227800,"is_prepared":null,"preparation_time":0,"type":"Hot Kitchen","label_id":null,"is_combo":0,"is_deleted":0,"deleted_at":null,"deleted_qty":null,"coupon_amount":0,"coupon_id":0,"coupon_code":null,"updated_by_user_ids":[],"re":null,"ri":null,"is_wastage":null,"discount_id":null,"discount_name":null,"round":1,"combo_items":null},{"id":1583205,"identifier":"2b9e5d71-6c0a-4f18-b3d2-7e1a9c4f8d02","name":"Mint Lemonade","name_localize":null,"actual_price":12,"base_price":12,"price":12,"is_weightage_item":0,"unit_id":null,"qty":1,"subtotal":12,"line_total":null,"line_vat":null,"line_total_without_vat":null,"invoice_uuid":"c1d2e3f4-5a6b-4c7d-8e9f-a0b1c2d3e4f5","product_id":52187,"extras":null,"vat":0,"is_updated":0,"updated_qty":0,"is_addon":0,"is_refunded":0,"refund_quantity":0,"refund_reason":null,"note":null,"addon_linked_with":null,"discount_pct":0,"discount_amount":0,"modifier":[],"transferred_to":0,"transferred_to_uuid":null,"transferred_to_uuid_list":[],"transferred_quantities":[],"transfer_recipients":[],"created_at":1790227800,"updated_at":1790227890,"is_prepared":1,"preparation_time":0,"type":"Beverages","label_id":null,"is_combo":0,"is_deleted":0,"deleted_at":null,"deleted_qty":null,"coupon_amount":0,"coupon_id":0,"coupon_code":null,"updated_by_user_ids":[],"re":null,"ri":null,"is_wastage":null,"discount_id":null,"discount_name":null,"round":1,"combo_items":null}],"order_type_icon":null}]},"parameters":{"type":"object","properties":{"c":{"type":"string","description":"Optional."},"is_order_rebump":{"type":"number","description":"Optional."},"brand_id":{"type":"number","description":"Optional."}},"required":[]},"module":"kds","safety":"read"},{"name":"update_kds_item_status","description":"PUT /api/v2/kds-item-status. Why: Tick (or un-tick) one line on the kitchen board — the web-pos KDS line tap. Writes invoice_items.is_prepared for that line only; the order stays on the board until it is bumped with change_kds_order_status. Single line per call (no bulk): call once per line. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_kds_item_status\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/v2/kds-item-status","why":"Tick (or un-tick) one line on the kitchen board — the web-pos KDS line tap. Writes invoice_items.is_prepared for that line only; the order stays on the board until it is bumped with change_kds_order_status. Single line per call (no bulk): call once per line.","response":{"description":"{ status, message }, always HTTP 200. status true does NOT always mean written — read message.","fields":{"status":"boolean. false only when item_id matches no line. true also for 'Please add … value' replies where NOTHING was written.","message":"string. Written: 'KDS item <name> is ready to served.' (is_prepared 1) or 'KDS item <name> not prepared yet.' (0). Not written: 'Please add is_prepared value to update status', 'Please add item_id to update status', 'No item found against this item_id.'"},"master_rules":["Get item_id from list_kds_orders order_items[].id (visible lines only: is_deleted ≠ 1 and is_addon ≠ 1). web-pos does not let you tick lines of a PENDING (is_accepted 0/null) or REJECTED (2) ticket — accept it first with update_kds_order_status status=1.","Body as web-pos sends: query item_id=<id>&is_prepared=\"1\" (tap) or \"0\" (tap again). One call per line; the server has no bulk form.","Only invoice_items.is_prepared changes: no timestamp, no invoice updated_at, no push/Ably/Firestore signal, no order status change. Other screens see it on their next poll (list_kds_orders, list_orders_history / list_dine_orders items[].is_prepared).","Ticking every line does NOT bump the order (unless a web-pos device has auto-bump on). Call change_kds_order_status to send it out.","Idempotent: sending the same value again just rewrites it.","sync_invoice updates of the same open order overwrite is_prepared with what they send — resend the current value to keep the kitchen ticks."],"caveats":"The server does not check that the line belongs to your tenant, branch or an open order. Any value is stored (tinyint); send only 0 or 1.","example":{"status":true,"message":"KDS item Chicken Shawarma is ready to served."}},"parameters":{"type":"object","properties":{"item_id":{"type":"number","description":"Required."},"is_prepared":{"type":"number","description":"Required."}},"required":["item_id","is_prepared"]},"module":"kds","safety":"write"},{"name":"update_kds_order_status","description":"PUT /api/v2/kds-order-status. Why: Accept or reject a ticket on the kitchen board — the web-pos KDS ACCEPT / REJECT buttons shown on PENDING tickets (is_accepted 0/null: online/aggregator orders and, in web-pos, new POS orders too). Writes invoices.is_accepted. Accepting an order whose status is 'pending_acceptance' also completes it (status 'completed'). Reject only flags the ticket (greyed CANCELLED on the KDS) — it does NOT cancel the sale; use cancel_invoice for that. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_kds_order_status\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/v2/kds-order-status","why":"Accept or reject a ticket on the kitchen board — the web-pos KDS ACCEPT / REJECT buttons shown on PENDING tickets (is_accepted 0/null: online/aggregator orders and, in web-pos, new POS orders too). Writes invoices.is_accepted. Accepting an order whose status is 'pending_acceptance' also completes it (status 'completed'). Reject only flags the ticket (greyed CANCELLED on the KDS) — it does NOT cancel the sale; use cancel_invoice for that.","response":{"description":"{ status, message }, always HTTP 200 and status always true — read message to know what happened.","fields":{"status":"boolean. Always true, even when nothing was written.","message":"string. Written: 'Kds Order <invoice no> is Accepted' | 'is rejected' | 'is in pending state' (0) | 'is updated' (other values); 'Order not synced yet.' when the order has no invoice number yet (still written). Not written: 'Please enter uuid of order you want to update', 'Please enter status you want to update', 'No invoice found against this uuid'."},"master_rules":["Call it for tickets with is_accepted 0/null (web-pos shows ACCEPT/REJECT only then). Body as web-pos: query uuid=<uuid>&status=1 (Accept) or status=2 (Reject).","Writes: invoices.is_accepted (and updated_at). status=1 on a 'pending_acceptance' order also sets status 'completed'. Publishes the derived state to Ably (accepted → 'preparing', rejected → 'cancelled') and, for Grubtech / Limetray orders, queues accept / reject to the aggregator. web-pos also publishes a Firestore signal so other tills refresh; MCP does not, they see it on their next poll.","Reject ≠ cancel: invoices.status is unchanged (the sale, stock and payments stay). The ticket stays on the board greyed until bumped (change_kds_order_status); to void the order use cancel_invoice (the order then leaves list_kds_orders).","Idempotent: repeating the same status rewrites it, but re-queues the aggregator accept/reject.","Ask the user before rejecting an online/aggregator order — the aggregator is told."],"caveats":"No tenant/branch check on uuid. Values other than 0/1/2 are stored as-is ('updated'); don't send them.","example":{"status":true,"message":"Kds Order 10433 is Accepted"}},"parameters":{"type":"object","properties":{"uuid":{"type":"string","description":"Required."},"status":{"type":"number","description":"Required."}},"required":["uuid","status"]},"module":"kds","safety":"write"},{"name":"change_kds_order_status","description":"PUT /api/v2/kds-change-status/:uuid. Why: BUMP a ticket off the kitchen board (order ready) or RECALL it back — the web-pos KDS DONE/bump button, dismiss of a rejected ticket, UNDO and the recall list. Bump sets invoices.done_at = now: the order leaves list_kds_orders and shows in the is_order_rebump=1 list; recall (is_order_rebump=1) clears done_at and it is back on the board. Bump also notifies: 'order ready' push to the branch's POS devices (with the table name for dine-in), Ably 'ready', a one-time WhatsApp 'order ready' to an opted-in guest, and — when the user's external_order_ready setting is on — 'prepared' to Grubtech / Limetray. It does NOT change the invoice status or payment. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"change_kds_order_status\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/v2/kds-change-status/:uuid","why":"BUMP a ticket off the kitchen board (order ready) or RECALL it back — the web-pos KDS DONE/bump button, dismiss of a rejected ticket, UNDO and the recall list. Bump sets invoices.done_at = now: the order leaves list_kds_orders and shows in the is_order_rebump=1 list; recall (is_order_rebump=1) clears done_at and it is back on the board. Bump also notifies: 'order ready' push to the branch's POS devices (with the table name for dine-in), Ably 'ready', a one-time WhatsApp 'order ready' to an opted-in guest, and — when the user's external_order_ready setting is on — 'prepared' to Grubtech / Limetray. It does NOT change the invoice status or payment.","response":{"description":"{ status, message } (sent as a JSON string). Bump: 'Order status updated'; recall: 'Order recalled successfully.'; unknown uuid on bump: status true 'No record found.'; DB error: HTTP 500 { status:false, message:'Something went wrong while updating order status.' }.","fields":{"status":"boolean. true = handled (also for an unknown uuid — check message). false only on HTTP 500.","message":"string. 'Order status updated' (bumped) | 'Order recalled successfully.' (recalled; also returned for an unknown uuid) | 'No record found.' (bump, uuid not found) | 'Something went wrong while updating order status.' (500)."},"master_rules":["Bump when the kitchen is done with the order (normally after every visible line is ticked with update_kds_item_status; the server does not check). web-pos: DONE button, or dismiss of a rejected (is_accepted 2) ticket, or auto-bump. Call with just the path uuid.","Recall: same call with is_order_rebump=1 — web-pos UNDO (within 8 s of a bump) and the Recall list (list_kds_orders is_order_rebump=1). Line ticks (is_prepared) are kept across bump/recall.","Bump writes invoices.done_at (and updated_at), visible as done_at in list_orders_history / list_dine_orders. It does not close, pay or cancel the order; a dine table stays open until paid (sync_invoice) or voided (cancel_invoice).","Side effects of a bump: POS-device push 'Order <no> is ready to deliver' / 'ready to serve on table <name>' + push_notifications row; Ably 'ready'; WhatsApp to an opted-in guest (once per order); aggregator 'prepared' when enabled. web-pos also publishes a Firestore 'ready'/'recalled' signal; MCP does not, so other tills see it on their next poll.","NOT idempotent for side effects: bumping again moves done_at to now and re-sends the device push (and aggregator status). Check list_kds_orders first. Recall is safe to repeat.","Bulk: none — one uuid per call."],"caveats":"The branch is not checked (tenant only). Recall of an unknown uuid still answers 'Order recalled successfully.'.","example":{"status":true,"message":"Order status updated"}},"parameters":{"type":"object","properties":{"uuid":{"type":"string","description":"Required."},"is_order_rebump":{"type":"number","description":"Optional."}},"required":["uuid"]},"module":"kds","safety":"write"},{"name":"list_sold_out_products","description":"GET /api/v2/products/sold-out. Why: Ids of the products switched to sold out at the branch (products_locations.is_sold_out = 1, set by update_product_availability). web-pos polls it every 60 s (and listens on Firestore) to grey out tiles. It is the same flag as is_sold_out on list_selling_products, but cheaper to refresh. It does NOT include products whose quota is used up (see list_product_quota). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_sold_out_products\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/products/sold-out","why":"Ids of the products switched to sold out at the branch (products_locations.is_sold_out = 1, set by update_product_availability). web-pos polls it every 60 s (and listens on Firestore) to grey out tiles. It is the same flag as is_sold_out on list_selling_products, but cheaper to refresh. It does NOT include products whose quota is used up (see list_product_quota).","response":{"description":"{ status, message, data } where data is a flat array of product ids (ints), unsorted. [] = nothing sold out. Error: 400 { status:false, message:'locationId is required', data:[] } when no branch is known; 500 { status:false, message, data:[] }.","fields":{"status":"boolean. true on success.","message":"string. 'Sold out products fetched'. NOT USED.","data":"int[]. Product ids (list_selling_products data.products[].id) that are sold out at the branch. USE: a product is sold out if its id is in this list."},"master_rules":["Sold out (web-pos) = product id in this list (is_sold_out = 1) OR the product has a quota in list_product_quota with remaining_stock = 0. Treat both the same: grey the tile, label it 'Sold Out', and do not add it to a cart (message 'This product is sold out.').","This list and list_selling_products[].is_sold_out read the same column; if they disagree, the newer call wins. Refresh with this tool (web-pos: every 60 s and 1.5 s after a local change) before building a cart.","Sold-out status never expires by itself: no daily job clears it. It stays until update_product_availability sets AVAILABLE.","Products switched off at the branch (products_locations.is_available = 0) are not on list_selling_products at all — that is a catalog setting (update_product), not sold out. Such an id can still appear here if its is_sold_out is 1; ignore it.","Add-ons (list_selling_addons) use the same flag: an add-on id in this list is sold out too."],"example":{"status":true,"message":"Sold out products fetched","data":[]}},"parameters":{"type":"object","properties":{"location_id":{"type":"string","description":"Optional."}},"required":[]},"module":"sold_out","safety":"read"},{"name":"update_product_availability","description":"POST /api/v2/products/availability. Why: The POS sold-out switch (long-press on a tile in web-pos): marks one product sold out (UNAVAILABLE) or back in stock (AVAILABLE) at one branch. Writes products_locations.is_sold_out (what list_sold_out_products and list_selling_products[].is_sold_out return), updates the Firestore list other tills listen to, and also pushes the change to Grubtech (delivery aggregator). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_product_availability\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/v2/products/availability","why":"The POS sold-out switch (long-press on a tile in web-pos): marks one product sold out (UNAVAILABLE) or back in stock (AVAILABLE) at one branch. Writes products_locations.is_sold_out (what list_sold_out_products and list_selling_products[].is_sold_out return), updates the Firestore list other tills listen to, and also pushes the change to Grubtech (delivery aggregator).","response":{"description":"{ status, message, data }. HTTP 200 status:true even when the Grubtech push fails (data.message then says 'Grubtech update availability failed'); check data.soldOutPersist.updated to know the SysPOS flag changed. Errors: 400 { status:false, message, data:{ message } } for a missing itemId, a bad availability, an unknown branch or brand; 500 { status:false, message }.","fields":{"status":"boolean. true = request handled (see soldOutPersist for the real result).","message":"string. 'Product have been updated.'. NOT USED.","data.message":"string. Grubtech result: 'Item availability updated on Grubtech' or 'Grubtech update availability failed'. Informational only; the failure is normal for branches not linked to Grubtech.","data.menuId":"string. Grubtech menu id (location-store-{branch}[-{brand}]-menu). NOT USED.","data.itemId":"string. Grubtech item id (PRO-{product id}). NOT USED.","data.storeId":"string. Grubtech store id (store-{branch}[-{brand}]). NOT USED.","data.availability":"string. Normalised value applied (AVAILABLE | UNAVAILABLE | HIDDEN).","data.itemType":"string. PRODUCT or MODIFIER_ITEM.","data.is_sold_out":"0 | 1 | null. Value written to products_locations.is_sold_out (null for MODIFIER_ITEM).","data.soldOutPersist":"object | null. SysPOS DB result (null for MODIFIER_ITEM).","data.soldOutPersist.updated":"boolean. USE: true = the branch row changed. false = nothing changed — the product has no row at this branch (not on its menu), the flag already had this value, or a DB error (then reason is set).","data.soldOutPersist.affectedRows":"int. Rows updated. NOT USED.","data.soldOutPersist.is_sold_out":"0 | 1. Same as data.is_sold_out.","data.soldOutPersist.reason":"string. Only when updated is false for a missing id ('missing_product_or_location') or 'db_error'.","data.soldOutPersist.error":"string. Only with reason 'db_error'.","data.firestoreSync":"object | null. Live sync for other tills (null for MODIFIER_ITEM).","data.firestoreSync.ok":"boolean. false = other tills only see the change on their next 60 s poll of list_sold_out_products.","data.firestoreSync.action":"string. 'set' (sold out) or 'delete' (available). NOT USED.","data.firestoreSync.docId":"string. '{product}_{branch}'. NOT USED.","data.firestoreSync.reason":"string. Only when ok is false.","data.firestoreSync.error":"string. Only when ok is false.","data.grubtechUrl":"string. Only when Grubtech failed. NOT USED.","data.hint":"string. Only when Grubtech answered 404. NOT USED.","data.grubtechStatus":"int. Only when Grubtech failed. NOT USED.","data.grubtechError":"any. Only when Grubtech failed. NOT USED."},"master_rules":["One product, one branch per call. To sell out several products, call once per product.","The change is effective immediately: list_sold_out_products and list_selling_products[].is_sold_out reflect it on the next call; web-pos tills update live (Firestore) or within 60 s.","Sold out does not expire: set AVAILABLE again to sell the product (no daily reset).","Use this tool for 'sold out / back in stock'. It does NOT touch quotas: a product whose quota is used up stays sold out after AVAILABLE — raise or reset the quota instead (reset_product_quota).","web-pos only lets cashiers whose user setting is_soldout = 1 toggle this, needs to be online, and uses UNAVAILABLE / AVAILABLE. Do the same (never HIDDEN unless asked for Grubtech).","To take a product off the menu for good, change its branch availability with update_product, not this."],"caveats":"HTTP 200 does not prove the change: read data.soldOutPersist.updated. Grubtech is always called; its failure is logged and ignored for products.","example":{"status":true,"message":"Product have been updated.","data":{"message":"Grubtech update availability failed","menuId":"location-store-907-menu","itemId":"PRO-257508","storeId":"store-907","availability":"UNAVAILABLE","itemType":"PRODUCT","is_sold_out":1,"soldOutPersist":{"updated":true,"affectedRows":1,"is_sold_out":1},"firestoreSync":{"ok":true,"action":"set","docId":"257508_907"},"grubtechUrl":"https://api.grubtech.io/menu/v1/menus/location-store-907-menu/items/PRO-257508/availability","hint":"menuId/storeId must match a menu synced via sync/products (e.g. storeId: store-59, menuId: location-store-59-menu). Send location_id to derive them automatically.","grubtechStatus":404,"grubtechError":{}}}},"parameters":{"type":"object","properties":{"itemId":{"type":"string","description":"Required."},"availability":{"type":"string","enum":["AVAILABLE","UNAVAILABLE","HIDDEN"],"description":"Required (case-insensitive)."},"itemType":{"type":"string","enum":["PRODUCT","MODIFIER_ITEM"],"description":"Optional."},"location_id":{"type":"string","description":"Optional."},"brandId":{"type":"string","description":"Optional."}},"required":["itemId","availability"]},"module":"sold_out","safety":"write"},{"name":"list_product_quota","description":"GET /api/v2/user-product-quota. Why: Product quotas of the branch: for each product with a quota, how many units may be sold (total_quota), how many were sold (sold_count) and what is left (remaining_stock). web-pos loads it to show 'N left' on tiles, to block adding more than remaining_stock to a cart, and on the Product Sold Counter page. A quota is per product per branch (not per user, despite the route name). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_product_quota\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/user-product-quota","why":"Product quotas of the branch: for each product with a quota, how many units may be sold (total_quota), how many were sold (sold_count) and what is left (remaining_stock). web-pos loads it to show 'N left' on tiles, to block adding more than remaining_stock to a cart, and on the Product Sold Counter page. A quota is per product per branch (not per user, despite the route name).","response":{"description":"{ status, message, data }. data = array of active quotas (one per product), or ONE object when assignment_id is sent. Only products with an active quota appear. Error: 404 for an unknown assignment_id; 500 { status:false, message, error }.","fields":{"status":"boolean. true on success.","message":"string. NOT USED.","data":"array (object when assignment_id is sent). Active quotas of the branch, ordered by product_id. [] = no quotas.","data[].counter_id":"int | null. Counter row id. THE id for reset_product_quota. null = nothing counted yet at this branch (nothing to reset).","data[].assignment_id":"int. Quota id (one active quota per product per branch). Pass as assignment_id to fetch one quota. The 409 from update_product_quota returns it.","data[].product_id":"int. Product id (list_selling_products data.products[].id). Match it to the menu to show 'N left' / sold out.","data[].product_name":"string | null. Branch name of the product (falls back to the master name). Show it.","data[].total_quota":"int. Units allowed to be sold (user_product_assignments.max_count). Show as Quota.","data[].sold_count":"int. Units counted as sold since the quota was created or last reset. Can exceed total_quota (the server never blocks a sale). Show as Sold.","data[].remaining_stock":"int. max(0, total_quota - sold_count). USE it: 0 = treat the product as sold out; otherwise do not let a cart hold more than this many units of the product.","data[].history":"array. Sale movements on this counter, oldest first. [] when counter_id is null. NOT USED by web-pos. May not add up to sold_count (reset deletes the rows; older sales have no row).","data[].history[].id":"int. Movement id. NOT USED.","data[].history[].quantity":"number. Negative = units counted as sold by an order line; positive = units given back (line removed, order cancelled). NOT USED by web-pos.","data[].history[].invoice_id":"int | null. Invoice id (often 0 or null). NOT USED.","data[].history[].counter_id":"int. Same as counter_id. NOT USED.","data[].history[].identifier":"string. Order-line identifier of the invoice item that moved the counter. NOT USED.","data[].history[].order_uuid":"string. uuid of the order (sync_invoice uuid). Use it to find the order that sold the units.","data[].history[].product_id":"int. Same as product_id. NOT USED.","data[].history[].created_at":"int. Unix seconds when the movement was recorded. NOT USED."},"master_rules":["Before adding a product to a cart (web-pos): if it has a quota row, the total quantity of that product across all cart lines (add-on lines excluded) plus the new quantity must be <= remaining_stock. If not, refuse with 'This product is sold out.' (remaining 0) or 'There is only N products in stock.'.","remaining_stock = 0 makes the product sold out on the menu (greyed tile) even if it is not in list_sold_out_products. Products without a quota row have no limit.","Tile badge (web-pos): show 'N left' when remaining_stock > 0.","Counting: sync_invoice adds each product line's quantity (minus deleted/refunded quantity; add-ons not counted) to sold_count when the order is open, or completed and paid. Drafts do not count. Removing a line or cancelling the order gives the units back. The server does NOT refuse an order over the quota — enforce it before sending.","No automatic daily reset: the counter keeps growing until reset_product_quota is called (the web-pos page says 'daily', but the reset is manual).","Refresh this list after each sync_invoice and before checking a cart; web-pos refetches on page mount and after every quota write."],"example":{"status":true,"message":"All product quotas retrieved successfully","data":[{"counter_id":null,"assignment_id":4,"product_id":257497,"product_name":"Espresso","total_quota":10,"sold_count":0,"remaining_stock":10,"history":[]},{"counter_id":null,"assignment_id":5,"product_id":257499,"product_name":"Caramel Latte","total_quota":10,"sold_count":0,"remaining_stock":10,"history":[]},{"counter_id":15848,"assignment_id":25,"product_id":257508,"product_name":"Mocha","total_quota":20,"sold_count":7,"remaining_stock":13,"history":[{"id":27,"quantity":-1,"invoice_id":0,"counter_id":15848,"identifier":"58556941-8651-4fbe-a683-8bf32143dfe7","order_uuid":"b7b056e0-3c5f-437d-9258-968b6cc34a6e","product_id":257508,"created_at":1787738590}]}]}},"parameters":{"type":"object","properties":{"location_id":{"type":"string","description":"Optional."},"product_id":{"type":"string","description":"Optional."},"assignment_id":{"type":"string","description":"Optional."}},"required":[]},"module":"sold_out","safety":"read"},{"name":"update_product_quota","description":"POST /api/v2/user-product-quota. Why: Create a quota for one product at the session branch: it may sell total_quota units, the sold counter starts at 0. CREATE ONLY — if the product already has an active quota the server answers 409 (it cannot change or delete one through MCP). Not used by web-pos (quotas are set on iOS / the dashboard). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_product_quota\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/v2/user-product-quota","why":"Create a quota for one product at the session branch: it may sell total_quota units, the sold counter starts at 0. CREATE ONLY — if the product already has an active quota the server answers 409 (it cannot change or delete one through MCP). Not used by web-pos (quotas are set on iOS / the dashboard).","response":{"description":"{ status, message, data } where data is the new quota row (same shape as a list_product_quota row). Branch = session branch (a location_id in the body is ignored). Errors: 400 { status:false, message } for missing product_id / total_quota or a negative quota; 409 { status:false, message, data:{ assignment_id, product_id } } when an active quota already exists; 500 { status:false, message }.","fields":{"status":"boolean. true = quota created.","message":"string. 'Quota created successfully'. NOT USED.","data.counter_id":"int | null. Counter row id. THE id for reset_product_quota. null = nothing counted yet at this branch (nothing to reset).","data.assignment_id":"int. Quota id (one active quota per product per branch). Pass as assignment_id to fetch one quota. The 409 from update_product_quota returns it.","data.product_id":"int. Product id (list_selling_products data.products[].id). Match it to the menu to show 'N left' / sold out.","data.product_name":"string | null. Branch name of the product (falls back to the master name). Show it.","data.total_quota":"int. Units allowed to be sold (user_product_assignments.max_count). Show as Quota.","data.sold_count":"int. Units counted as sold since the quota was created or last reset. Can exceed total_quota (the server never blocks a sale). Show as Sold.","data.remaining_stock":"int. max(0, total_quota - sold_count). USE it: 0 = treat the product as sold out; otherwise do not let a cart hold more than this many units of the product.","data.history":"array. Sale movements on this counter, oldest first. [] when counter_id is null. NOT USED by web-pos. May not add up to sold_count (reset deletes the rows; older sales have no row).","data.history[].id":"int. Movement id. NOT USED.","data.history[].quantity":"number. Negative = units counted as sold by an order line; positive = units given back (line removed, order cancelled). NOT USED by web-pos.","data.history[].invoice_id":"int | null. Invoice id (often 0 or null). NOT USED.","data.history[].counter_id":"int. Same as counter_id. NOT USED.","data.history[].identifier":"string. Order-line identifier of the invoice item that moved the counter. NOT USED.","data.history[].order_uuid":"string. uuid of the order (sync_invoice uuid). Use it to find the order that sold the units.","data.history[].product_id":"int. Same as product_id. NOT USED.","data.history[].created_at":"int. Unix seconds when the movement was recorded. NOT USED."},"master_rules":["Call list_product_quota?product_id=… first: if a row exists you get 409 and cannot change it here. To start counting again from 0 on an existing quota use reset_product_quota.","Creating a quota sets the product's counter to 0 (earlier sales of that product at the branch no longer count).","The quota applies to every till and user of the session branch."],"caveats":"data.history may list old movements of the product: the counter is zeroed but its history rows are not removed.","example":{"status":true,"message":"Quota created successfully","data":{"counter_id":15848,"assignment_id":26,"product_id":257508,"product_name":"Mocha","total_quota":20,"sold_count":0,"remaining_stock":20,"history":[]}}},"parameters":{"type":"object","properties":{"product_id":{"type":"string","description":"Required."},"total_quota":{"type":"string","description":"Required."}},"required":["product_id","total_quota"]},"module":"sold_out","safety":"write"},{"name":"mark_product_sold_out","description":"POST /api/v2/user-product-quota/mark-sold-out. Why: Use up a product's quota at the session branch: sets sold_count = total_quota so remaining_stock becomes 0 (sold out on the menu). Only works for products that HAVE a quota (404 otherwise). It does NOT set is_sold_out — for a product without a quota use update_product_availability. Not used by web-pos. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"mark_product_sold_out\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/v2/user-product-quota/mark-sold-out","why":"Use up a product's quota at the session branch: sets sold_count = total_quota so remaining_stock becomes 0 (sold out on the menu). Only works for products that HAVE a quota (404 otherwise). It does NOT set is_sold_out — for a product without a quota use update_product_availability. Not used by web-pos.","response":{"description":"{ status, message, data }. Errors: 400 { status:false, message:'product_id is required' }; 404 { status:false, message:'Product quota assignment not found' }; 500 { status:false, message }.","fields":{"status":"boolean. true = marked.","message":"string. 'Product marked as sold out'. NOT USED.","data.product_id":"number | string. The product_id you sent, echoed as sent.","data.total_quota":"int. The quota (unchanged).","data.sold_count":"int. Now equal to total_quota.","data.remaining_stock":"int. Always 0."},"master_rules":["The product is not added to list_sold_out_products; it shows sold out because list_product_quota has remaining_stock = 0.","Undo with reset_product_quota (counter_id from list_product_quota): sold_count goes back to 0, so the full quota is sellable again."],"example":{"status":true,"message":"Product marked as sold out","data":{"product_id":257508,"total_quota":20,"sold_count":20,"remaining_stock":0}}},"parameters":{"type":"object","properties":{"product_id":{"type":"string","description":"Required."}},"required":["product_id"]},"module":"sold_out","safety":"write"},{"name":"reset_product_quota","description":"POST /api/v2/user-product-quota/:counterId/reset. Why: Start a quota over: sets the counter's sold_count to 0 (remaining_stock = total_quota again) and deletes its sale history. The quota itself (total_quota) is kept. This is the only 'daily reset' — nothing resets counters automatically. web-pos has this button on the Product Sold Counter page but it is currently hidden. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"reset_product_quota\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/v2/user-product-quota/:counterId/reset","why":"Start a quota over: sets the counter's sold_count to 0 (remaining_stock = total_quota again) and deletes its sale history. The quota itself (total_quota) is kept. This is the only 'daily reset' — nothing resets counters automatically. web-pos has this button on the Product Sold Counter page but it is currently hidden.","response":{"description":"{ status, message, data }. Errors: 404 { status:false, message:'Counter not found' }; 500 { status:false, message }.","fields":{"status":"boolean. true = reset.","message":"string. 'Counter reset successfully'. NOT USED.","data.counter_id":"int. The counter you reset.","data.product_id":"int. Its product.","data.sold_count":"int. Always 0."},"master_rules":["Only pass a counter_id you got from list_product_quota for the session branch; the server does not check the branch.","Resetting does not change is_sold_out: a product also switched off with update_product_availability stays sold out."],"example":{"status":true,"message":"Counter reset successfully","data":{"counter_id":15848,"product_id":257508,"sold_count":0}}},"parameters":{"type":"object","properties":{"counterId":{"type":"string","description":"Required."}},"required":["counterId"]},"module":"sold_out","safety":"write"},{"name":"get_active_shift","description":"GET /api/v2/shifts/active. Why: Get the active shift for this device/user. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_active_shift\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/shifts/active","why":"Get the active shift for this device/user.","response":{"description":"Active shift or null.","example":{"id":1,"status":"open"}},"parameters":{"type":"object","properties":{},"required":[]},"module":"shifts","safety":"read"},{"name":"list_shifts","description":"GET /api/v2/shifts. Why: List shifts. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_shifts\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/shifts","why":"List shifts.","response":{"description":"Shift list.","example":[{"id":1}]},"parameters":{"type":"object","properties":{"page":{"type":"string","description":"Page number (1-based)."},"limit":{"type":"string","description":"Rows per page."}},"required":[]},"module":"shifts","safety":"read"},{"name":"shift_clock_in","description":"POST /api/v2/shifts/clock-in. Why: Clock in / open shift. Body typically opening cash + device_id. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"shift_clock_in\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/v2/shifts/clock-in","why":"Clock in / open shift. Body typically opening cash + device_id.","response":{"description":"JSON payload.","example":{"status":true}},"parameters":{"type":"object","properties":{"opening_cash":{"type":"string","description":"Cash in the drawer at clock-in (float)."},"device_id":{"type":"string","description":"POS device identifier the shift runs on (optional)."},"location_id":{"type":"string","description":"Location id, csv, or array — the branch(es) this row belongs to."},"notes":{"type":"string","description":"Opening note for the shift."},"denominations":{"type":"string","description":"Cash-count per note: { \"500\": n, \"200\": n, \"100\": n, \"50\": n, \"20\": n, \"10\": n, \"5\": n, \"1\": n, \"coins\": amount }."}},"required":[]},"module":"shifts","safety":"write"},{"name":"shift_clock_out","description":"POST /api/v2/shifts/clock-out. Why: Clock out / close shift. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"shift_clock_out\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/v2/shifts/clock-out","why":"Clock out / close shift.","response":{"description":"JSON payload.","example":{"status":true}},"parameters":{"type":"object","properties":{"closing_cash":{"type":"string","description":"Cash counted at clock-out."},"notes":{"type":"string","description":"Closing note for the shift."},"denominations":{"type":"string","description":"Cash-count per note: { \"500\": n, \"200\": n, \"100\": n, \"50\": n, \"20\": n, \"10\": n, \"5\": n, \"1\": n, \"coins\": amount }."}},"required":[]},"module":"shifts","safety":"write"},{"name":"list_drawer_categories","description":"GET /api/v2/shifts/drawer-categories. Why: Cash in/out category list. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_drawer_categories\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/shifts/drawer-categories","why":"Cash in/out category list.","response":{"description":"Drawer categories.","example":[{"id":1,"name":"Petty cash"}]},"parameters":{"type":"object","properties":{},"required":[]},"module":"shifts","safety":"read"},{"name":"list_drawer_transactions","description":"GET /api/v2/shifts/drawer-transactions. Why: List cash drawer transactions for the shift. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_drawer_transactions\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/shifts/drawer-transactions","why":"List cash drawer transactions for the shift.","response":{"description":"Drawer txs.","example":[{"id":1,"amount":10}]},"parameters":{"type":"object","properties":{"shift_id":{"type":"string","description":"Shift id (list_shifts / get_active_shift)."}},"required":[]},"module":"shifts","safety":"read"},{"name":"create_drawer_transaction","description":"POST /api/v2/shifts/drawer-transactions. Why: Record cash in/out. Include uuid for idempotency when POS does. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_drawer_transaction\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/v2/shifts/drawer-transactions","why":"Record cash in/out. Include uuid for idempotency when POS does.","response":{"description":"JSON payload.","example":{"status":true}},"parameters":{"type":"object","properties":{"uuid":{"type":"string","description":"Client-generated UUID for this cash movement (idempotency key)."},"shift_id":{"type":"string","description":"Open shift id (get_active_shift)."},"direction":{"type":"string","enum":["in","out"],"description":"'in' = cash added to the drawer (paid-in), 'out' = cash removed (paid-out / expense)."},"category_id":{"type":"string","description":"Drawer category id (list_drawer_categories), e.g."},"amount":{"type":"string","description":"Cash amount, > 0."},"description":{"type":"string","description":"What the movement was for — printed on the shift report."},"receipt_ref":{"type":"string","description":"External receipt / voucher reference (optional)."},"brand_id":{"type":"string","description":"Brand id when the location runs multiple brands (optional)."}},"required":["uuid","shift_id","direction","category_id","amount","description"]},"module":"shifts","safety":"write"},{"name":"get_tills_summary","description":"GET /api/v2/shifts/tills-summary. Why: Per-user tills summary for a shift. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_tills_summary\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/shifts/tills-summary","why":"Per-user tills summary for a shift.","response":{"description":"Tills summary rows.","example":[{"user_id":1,"total":100}]},"parameters":{"type":"object","properties":{"shift_id":{"type":"string","description":"Shift id (list_shifts / get_active_shift)."}},"required":[]},"module":"shifts","safety":"read"},{"name":"get_ol_info","description":"GET /api/v2/ol-info. Why: Last order of the branch's CURRENT business day (its daily order_no and running receipt number customer_invoice_id) plus that day's UTC window. web-pos caches it at boot but reads nothing from it. Use it to see the last numbers before/after sync_invoice (the server assigns both) or the UTC bounds of 'today'. Hours/time zone come from get_pos_configs. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_ol_info\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/ol-info","why":"Last order of the branch's CURRENT business day (its daily order_no and running receipt number customer_invoice_id) plus that day's UTC window. web-pos caches it at boot but reads nothing from it. Use it to see the last numbers before/after sync_invoice (the server assigns both) or the UTC bounds of 'today'. Hours/time zone come from get_pos_configs.","response":{"description":"A BARE object (no status envelope). Business day = the SERVER's current date at the branch opening_hour (config, default 05:00) in the branch time_zone (config, default +04:00), for 24 h, converted to UTC. The newest invoice (any status) created in that window; zeros when none. Errors: 400 { error: \"Business hours not found\" }; 500 { error: \"Failed to fetch invoice data\" }; 401 when not authenticated.","fields":{"id":"int. invoices.id of the newest order of today's business day; 0 = no order yet today. NOT USED by web-pos.","invoice_id":"int. Its customer_invoice_id: the running receipt number (never resets); 0 = none today. USE: last receipt number.","order_id":"int. Its order_no: the daily order number (restarts each business day); 0 = none today. USE: last daily order number.","username":"string. Login email of the session user. NOT USED.","customer_id":"int. Tenant id. NOT USED.","location_id":"int | string. Branch the numbers belong to (a string when location_id was sent).","fromDate":"string. \"YYYY-MM-DD HH:mm:ss\" in UTC: start of the current business day.","toDate":"string. \"YYYY-MM-DD HH:mm:ss\" in UTC: end of the business day (= next day's start)."},"master_rules":["web-pos loads it once at boot, in parallel with the other master data (fetchAndStore.js, 'Fetching order information'), and stores it in IndexedDB 'ol-info'. No screen or calculation reads it.","sync_invoice assigns order_no and customer_invoice_id itself. Never send numbers derived from this tool.","The day is picked from the SERVER date, not the branch date. Near midnight, in a branch whose time zone differs from the server's, the window can be one day off. get_today_sales uses the same opening_hour + time_zone configs.","Links: opening_hour / time_zone = get_pos_configs keys; get_user_extras shows the same hours as \"10:00 AM\"."],"caveats":"Open and cancelled orders count too (newest by created_at). On dev1 the demo branch had no order today, so every number is 0.","example":{"id":0,"invoice_id":0,"order_id":0,"username":"cashier@example.com","customer_id":437,"location_id":907,"fromDate":"2026-09-24 05:00:00","toDate":"2026-09-25 05:00:00"}},"parameters":{"type":"object","properties":{"location_id":{"type":"string","description":"Optional."}},"required":[]},"module":"pos_session","safety":"read"},{"name":"get_user_extras","description":"GET /api/v2/user-extras-v3. Why: Branch + user configuration web-pos loads at boot: receipt header (name, address, VAT no.), flat VAT %, currency, money decimals, business-day hours, the user's max manual discount %, active discount codes, the void PIN and the ios_modules feature flags (is_loyalty_enable, is_branding_enable, is_activities_enabled). Read it before pricing an order for sync_invoice (VAT fallback, decimal_points) or offering discounts. The per-user POS switches (is_channels_enable, is_soldout, discount_codes, is_change_payment_method_enabled, tip_amount, dining_guest_count, combo_options) are NOT here: they are list_pos_users data[].setting. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_user_extras\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/user-extras-v3","why":"Branch + user configuration web-pos loads at boot: receipt header (name, address, VAT no.), flat VAT %, currency, money decimals, business-day hours, the user's max manual discount %, active discount codes, the void PIN and the ios_modules feature flags (is_loyalty_enable, is_branding_enable, is_activities_enabled). Read it before pricing an order for sync_invoice (VAT fallback, decimal_points) or offering discounts. The per-user POS switches (is_channels_enable, is_soldout, discount_codes, is_change_payment_method_enabled, tip_amount, dining_guest_count, combo_options) are NOT here: they are list_pos_users data[].setting.","response":{"description":"{ status: true, message: \"Data found\", data }. Always the session user's own branch (JWT location_id) — no branch parameter. No error handler: a DB failure leaves the call unanswered (tool timeout). 401 when not authenticated.","fields":{"status":"boolean. true.","message":"string. \"Data found\".","data":"object. Everything below.","data.today_guests":"int. Σ dining_guests of COMPLETED orders of today's business day. Computed only when some user of the branch has setting dining_guest_count = 1, else 0. NOT USED by web-pos.","data.can_preview_invoice":"0 | 1. users.can_preview_invoice of the session user. NOT USED by web-pos.","data.max_discount_permit":"int (percent). Max MANUAL discount % this user may give. USE: web-pos caps the manual order / line discount at this % of the total (DiscountDialog). 0 = no manual discount.","data.discount_code":"array. Active discount codes of the branch: is_active = 1 and today (DB date) within start_date..end_date; master tenants via discounts_locations. Full discounts rows. USE: the code list in the discount dialog when the user setting discount_codes = 1. [] = none. Manage with list_discounts.","data.discount_code[].id":"int. Discount id.","data.discount_code[].name":"string. Shown in the discount-code list.","data.discount_code[].discount_method":"string | null. \"amount\" (fixed money) or \"percentage\"; anything else = percentage.","data.discount_code[].discount_value":"number. The amount or percent (percent capped at 100). USE: this, not discount_percent.","data.discount_code[].discount_percent":"number | null. Legacy percent column. NOT USED by web-pos.","data.discount_code[].max_cap_amount":"number | null. Max money value of a percentage code (the % is re-derived to fit the cap).","data.discount_code[].min_basket_value":"number | null. Order total must be ≥ this, else the code is listed as ineligible.","data.discount_code[].is_active":"0 | 1. Always 1 here (the query filters it).","data.discount_code[].start_date":"string. ISO date-time from a DATE column. web-pos reads the date/time digits as LOCAL time (ignores the Z).","data.discount_code[].end_date":"string. ISO date-time. Valid through the whole end day.","data.discount_code[].start_time":"string. \"HH:mm:ss\" daily window start (default 00:00:00).","data.discount_code[].end_time":"string. \"HH:mm:ss\" daily window end (default 23:59:59; a window may wrap midnight).","data.discount_code[].is_available_on":"string. CSV of lowercase weekday names; empty = every day.","data.discount_code[].apply_discount_to":"string. \"product\" = per matching line (product_ids; empty list = every line); otherwise category_ids non-empty = per matching category line; else the whole order.","data.discount_code[].product_ids":"string | null. CSV of product ids (list_selling_products).","data.discount_code[].category_ids":"string | null. CSV of category ids.","data.discount_code[].channel_ids":"string | null. CSV of channel ids (list_selling_channels). Enforced only when the user setting is_channels_enable = 1.","data.discount_code[].payment_methods":"string | null. CSV of payment-method ids. Shown only; web-pos does not enforce it.","data.discount_code[].order_type_ids":"string | null. CSV of order-type ids. NOT USED by web-pos.","data.discount_code[].discount_type":"int. Dashboard type code. NOT USED by web-pos.","data.discount_code[].is_auto_apply":"0 | 1 | null. Shown as a label; web-pos does not auto-apply.","data.discount_code[].is_promotion":"0 | 1 | null. NOT USED by web-pos.","data.discount_code[].location_id":"int | null. Owner branch (null on master tenants; the link is discounts_locations).","data.discount_code[].customer_id":"int. Tenant id.","data.opening_hour":"string. Business-day start, \"h:mm AM|PM\" (config opening_hour; \"00:00 AM\" when unset). USE: web-pos business-day boundary (falls back to \"05:00 AM\").","data.closing_hour":"string. Business-day end, same format (config closing_hour). USE: fallback \"11:55 PM\".","data.business_day_start":"string. \"HH:mm\" opening hour. Returned by the dev1 build only (not in the checked-out backend). NOT USED by web-pos.","data.business_logo":"string. Full URL of the branch logo (config logo_image), \"\" when none. USE: receipts (web-pos also caches a base64 copy).","data.business_config":"object. Branch business header. USE: web-pos spreads it into redux businessConfig (setPreStates.js).","data.business_config.info_name":"string. Business name. USE: receipt header and KDS ticket fallback. Only present when the config key exists (same for every info_* key).","data.business_config.legal_name":"string | null. Legal name; null when blank. NOT USED by web-pos.","data.business_config.info_address":"string. Address. USE: receipt header.","data.business_config.info_phone":"string. Phone. USE: receipt header.","data.business_config.info_email":"string. Email. USE: receipt header.","data.business_config.vat_no":"string. VAT registration no. (config info_vat_no). USE: printed as TRN / VAT No# on receipts and sales reports.","data.business_config.vat":"string. Flat VAT % of the branch, e.g. \"5\" (config vat; a STRING, parse it). USE: fallback VAT % when get_pos_configs has no vat key. Same config row, so both sources agree.","data.business_config.polygon":"array. Delivery area as { latitude, longitude } points (config polygon_coordinates). Only when set. NOT USED by web-pos.","data.business_config.period":"string. Name of the branch's latest periods row. Only when one exists. NOT USED by web-pos.","data.business_config.is_close":"0 | 1. That period is closed. NOT USED by web-pos.","data.business_config.exchange_rate":"number. Secondary-currency rate (config exchange_rate). Only when set. NOT USED by web-pos checkout.","data.business_config.pincode":"int. 4-digit business PIN (configuration key pincode; default 9999). USE: web-pos asks for it before Cancel / Void (cancel_invoice). Never show or echo it; agents ask the user for confirmation instead.","data.business_config.currency":"object. Currency config row { id, name, value, customer_id, location_id } — the branch row, else any row of the tenant, else a synthetic { name, value: \"AED\" }. USE: currency code = .value; web-pos prefers get_pos_configs currency, then this; \"\" → AED.","data.business_config.currency.id":"int. Config row id (absent in the synthetic default).","data.business_config.currency.name":"string. Always \"currency\".","data.business_config.currency.value":"string. Currency code, e.g. \"AED\"; \"\" = not set (treat as AED).","data.business_config.currency.customer_id":"int. Tenant id.","data.business_config.currency.location_id":"int. Branch of that config row (may be another branch when the session branch has none).","data.decimal_points":"int. Money decimals (config decimal_points). USE: web-pos rounds every amount half-even to this many decimals (money.js). Missing config returns 0, not 2 (server bug) — see caveats.","data.ios_modules":"object. Feature flags of the session user (users.ios_modules, positional ';' string; missing trailing flags default as noted) + the tenant activity flag. Values 0 | 1.","data.ios_modules.is_kds_enable":"0 | 1. KDS module licensed. NOT USED by web-pos (Android/iOS).","data.ios_modules.is_daily_sale_report_enable":"0 | 1. Daily-sales screen allowed. NOT USED by web-pos.","data.ios_modules.is_order_hisotry_screen_enable":"0 | 1. Order-history screen allowed (key misspelled by the server). NOT USED by web-pos.","data.ios_modules.is_sms_enable":"0 | 1. SMS receipts. NOT USED by web-pos.","data.ios_modules.is_cloud_print_enable":"0 | 1. Cloud printing. NOT USED by web-pos.","data.ios_modules.is_user_blocked":"0 | 1. 1 = user blocked in the apps. NOT USED by web-pos.","data.ios_modules.is_branding_enable":"0 | 1. USE: web-pos home route: 1 = brand picker first (when brands exist), 0 = straight to products (app/page.jsx).","data.ios_modules.is_actual_price":"0 | 1. Actual-price module (reports use products.actual_price). NOT USED by web-pos.","data.ios_modules.is_usd_base":"0 | 1. USD base currency (Android). NOT USED by web-pos.","data.ios_modules.is_dine_in_enable":"0 | 1. Dine-in module (default 1 when missing). NOT USED by web-pos.","data.ios_modules.is_wastage_enable":"0 | 1. Wastage module (default 1). NOT USED by web-pos.","data.ios_modules.is_material_stock_enable":"0 | 1. Material stock module (default 1). NOT USED by web-pos.","data.ios_modules.is_balance_enable":"0 | 1. Cash balance module (default 1). NOT USED by web-pos.","data.ios_modules.is_product_scanner_enable":"0 | 1. Product scanner (default 1). NOT USED by web-pos.","data.ios_modules.is_bar_scanner_enable":"0 | 1. Barcode scanner (default 1). NOT USED by web-pos.","data.ios_modules.is_devices_enable":"0 | 1. Devices module (default 1). NOT USED by web-pos.","data.ios_modules.is_peer_enable":"0 | 1. Peer (socket) sync (default 1). NOT USED by web-pos.","data.ios_modules.is_setting_enable":"0 | 1. Settings screen allowed (default 1). NOT USED by web-pos.","data.ios_modules.is_loyalty_enable":"0 | 1. USE: loyalty on/off. 1 (or true / \"1\") → web-pos shows the loyalty card for an attached customer and sends is_loyalty_enable 1 on the order (sync_invoice); 0 → no loyalty. Gate for get_loyalty_card / validate_loyalty_burn.","data.ios_modules.is_ticket_enabled":"0 | 1. Order tickets (Android). NOT USED by web-pos.","data.ios_modules.is_wallet_enabled":"0 | 1. Customer wallet (Android). NOT USED by web-pos (it keys wallet off the payment method type).","data.ios_modules.is_location_service_enabled":"0 | 1. Location service (default 1). NOT USED by web-pos.","data.ios_modules.is_crm_mobile_mandatory":"0 | 1. CRM customer must have a mobile (Android). NOT USED by web-pos.","data.ios_modules.reload_dine_order":"0 | 1. Reload dine orders (Android). NOT USED by web-pos.","data.ios_modules.order_dispatch_feature":"0 | 1. Order dispatch (Android). NOT USED by web-pos.","data.ios_modules.is_activities_enabled":"0 | 1. TENANT flag (customers.is_activities_enabled, added by this route only). USE: 0 → web-pos logs no activities (log_invoice_activities) and hides the order Activity section (get_invoice_activities); 1 → on.","data.tos":"string. Receipt footer (config receipt_footer_text) as HTML; \\QR <url> becomes {linkN}. NOT USED by web-pos.","data.tos_links":"object. { linkN: QR image URL } for the {linkN} placeholders in tos. NOT USED by web-pos.","data.receipt_header":"string. Receipt header (config reciept_header_text) as HTML, same markup. NOT USED by web-pos.","data.receipt_header_links":"object. { linkN: QR image URL } for receipt_header. NOT USED by web-pos.","data.is_shift_enabled":"0 | 1. Shift management on for the branch (config is_shift_enabled; default 1). USE: web-pos shift gate (shift tools are out of scope here)."},"master_rules":["Boot (web-pos): fetchAndStore.js fetches ~19 master-data sources in parallel (floors, dine orders, brands, ol-info, users-list, user-extras-v3, get-channels, categories, materials, user-location, get-all-configs, order types, payment methods, modifiers, product_materials, tables, products, CRM users), replaces IndexedDB, then setPreStates.js builds redux businessConfig from this route + the get-all-configs rows. Reloads skip the fetch while IndexedDB is complete.","VAT % (setPreStates.js): get_pos_configs key vat, else business_config.vat. Both read the same config row. Number(\"\") = 0 → no VAT. Use it for sync_invoice line / order vat and vat_pct.","Decimals: data.decimal_points drives all rounding in web-pos (money.js getDecimalPoints; 2 only when the field is missing or not a number). Use the same value in sync_invoice instead of a fixed 2.","Currency: get_pos_configs key currency, else business_config.currency.value, else AED. Time zone is NOT here: get_pos_configs time_zone (else time_zone_code, else +04:00) → sync_invoice seconds_from_gmt.","Discounts: manual discount ≤ max_discount_permit %. Discount codes (data.discount_code) are offered only when the user setting discount_codes = 1 (list_pos_users) and are checked client-side: is_active, start/end date, weekday, time window, min_basket_value, max_cap_amount, and channel_ids when is_channels_enable = 1. The same discount_codes switch also shows web-pos's coupon entry (list_selling_coupons / validate_coupon).","Loyalty: only when ios_modules.is_loyalty_enable = 1 AND a customer is attached (get_loyalty_card, validate_loyalty_burn); web-pos copies the flag onto the order as is_loyalty_enable.","Activity log: ios_modules.is_activities_enabled = 0 → no log_invoice_activities writes and no Activity section (get_invoice_activities).","Void PIN: business_config.pincode guards Cancel in web-pos (cancel_invoice). An agent must get explicit user confirmation instead of using it."],"caveats":"decimal_points: when the tenant has no decimal_points config the server returns 0, not the intended default 2 (null >= 0 is true in JS) — dev1 tenant 437 gets 0, so web-pos rounds to whole units there. business_config.vat is a STRING. Discount-code dates use the DB server date, not the branch date. business_day_start exists only on the deployed dev1 build.","example":{"status":true,"message":"Data found","data":{"today_guests":0,"can_preview_invoice":0,"max_discount_permit":50,"discount_code":[{"id":101,"name":"Happy Hour 10","discount_percent":null,"discount_method":"percentage","discount_value":10,"max_cap_amount":20,"min_basket_value":30,"location_id":null,"is_active":1,"start_date":"2026-09-01T00:00:00.000Z","end_date":"2026-12-31T00:00:00.000Z","discount_type":0,"category_ids":"","apply_discount_to":"entire_basket","product_ids":"","start_time":"15:00:00","end_time":"18:00:00","is_available_on":"monday,tuesday,wednesday,thursday,friday","is_auto_apply":0,"is_promotion":0,"payment_methods":"","channel_ids":"","customer_id":437,"order_type_ids":""}],"opening_hour":"10:00 AM","closing_hour":"08:00 AM","business_logo":"https://dev1.syspos.ae/api/uploads/example-logo.png","business_config":{"vat":"5","info_name":"Example Cafe","info_address":"Shop 1, Example Street","info_email":"info@example-cafe.test","vat_no":"100000000000003","info_phone":"+971500000000","legal_name":null,"exchange_rate":12,"pincode":1234,"currency":{"id":10103,"name":"currency","value":"","customer_id":437,"location_id":907}},"decimal_points":0,"ios_modules":{"is_kds_enable":1,"is_daily_sale_report_enable":1,"is_order_hisotry_screen_enable":1,"is_sms_enable":1,"is_cloud_print_enable":1,"is_user_blocked":0,"is_branding_enable":1,"is_actual_price":1,"is_usd_base":0,"is_dine_in_enable":1,"is_wastage_enable":1,"is_material_stock_enable":1,"is_balance_enable":1,"is_product_scanner_enable":1,"is_bar_scanner_enable":1,"is_devices_enable":1,"is_peer_enable":1,"is_setting_enable":1,"is_loyalty_enable":1,"is_ticket_enabled":0,"is_wallet_enabled":1,"is_location_service_enabled":1,"is_crm_mobile_mandatory":0,"reload_dine_order":1,"order_dispatch_feature":0,"is_activities_enabled":0},"tos":"<ul><li>Optional note</li></ul>","tos_links":{},"receipt_header":"","receipt_header_links":{},"is_shift_enabled":1,"business_day_start":"10:00"}}},"parameters":{"type":"object","properties":{},"required":[]},"module":"pos_session","safety":"read"},{"name":"get_user_location","description":"GET /api/v2/user-location. Why: The session user's home branch row (locations table): id, name and integration ids. web-pos caches it at boot for the N-Genius card terminal ids. Use it to learn which branch the session sells in (the branch sync_invoice and every list_selling_* tool use). For all branches in the key's scope use list_locations. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_user_location\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/user-location","why":"The session user's home branch row (locations table): id, name and integration ids. web-pos caches it at boot for the N-Genius card terminal ids. Use it to learn which branch the session sells in (the branch sync_invoice and every list_selling_* tool use). For all branches in the key's scope use list_locations.","response":{"description":"A BARE ARRAY with the ONE locations row of the session branch (select *), or [] when not found. No parameters; always the JWT location_id. On a DB error the call answers nothing useful (undefined body). 401 when not authenticated.","fields":{"[]":"array. 0 or 1 row.","[].id":"int. Branch id (= session location_id).","[].name":"string. Branch name.","[].customer_id":"int. Tenant id.","[].is_ware_house":"string | null. \"1\" = also a warehouse. NOT USED by web-pos.","[].is_primary":"string | null. Primary branch marker. NOT USED by web-pos.","[].otter_location_id":"string | null. Otter integration id. NOT USED by web-pos.","[].deliverect_location_id":"string | null. Deliverect id. NOT USED by web-pos.","[].enable_menu_checkout":"0 | 1 | null. Online menu checkout on. NOT USED by web-pos.","[].invoice_service_version":"string. Invoice pipeline version (\"v1\"). NOT USED by web-pos.","[].payment_provider":"string | null. Online payment provider. NOT USED by web-pos.","[].ngenius_terminal_id":"string | null. N-Genius card terminal id. USE: web-pos card-terminal flow needs this AND ngenius_merchant_id (out of scope for agents).","[].ngenius_merchant_id":"string | null. N-Genius merchant id (see ngenius_terminal_id).","[].enable_grubtech":"0 | 1. Grubtech integration on. NOT USED by web-pos.","[].allow_grubtech_status_updates":"0 | 1. Push status to Grubtech. NOT USED by web-pos.","[].enable_limetray":"0 | 1. Limetray integration on (column present in the backend DB; not returned by dev1). NOT USED by web-pos.","[].allow_limetray_status_updates":"0 | 1. Push status to Limetray (same note). NOT USED by web-pos."},"master_rules":["web-pos stores it in IndexedDB 'user-location' at boot; only the card-terminal code reads it (ngenius.js: both ngenius_* ids set → terminal payment).","The session branch is fixed by the API key user; list_locations / get_location show the other branches."],"caveats":"select * — columns added to locations later appear here too.","example":[{"id":907,"name":"Default location","customer_id":437,"is_ware_house":"1","is_primary":null,"otter_location_id":null,"deliverect_location_id":null,"enable_menu_checkout":null,"invoice_service_version":"v1","payment_provider":null,"ngenius_terminal_id":null,"ngenius_merchant_id":null,"enable_grubtech":0,"allow_grubtech_status_updates":0}]},"parameters":{"type":"object","properties":{},"required":[]},"module":"pos_session","safety":"read"},{"name":"list_pos_users","description":"GET /api/v2/users-list. Why: POS users of the session branch with their POS switches (data[].setting). Use it to pick the cashier ids for sync_invoice (created_by_user_id / close_by_user_id / user email), for get_today_sales close_by_user_id and log_invoice_activities, and to read the per-user feature switches web-pos obeys: is_channels_enable + channel_id (list_selling_channels), is_soldout (update_product_availability), discount_codes (discount codes + list_selling_coupons / validate_coupon), is_change_payment_method_enabled (update_invoice_payment_methods), tip_amount, dining_guest_count, combo_options, pay_zero_amount. The session user's own row = the switches that apply to MCP orders. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_pos_users\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/users-list","why":"POS users of the session branch with their POS switches (data[].setting). Use it to pick the cashier ids for sync_invoice (created_by_user_id / close_by_user_id / user email), for get_today_sales close_by_user_id and log_invoice_activities, and to read the per-user feature switches web-pos obeys: is_channels_enable + channel_id (list_selling_channels), is_soldout (update_product_availability), discount_codes (discount codes + list_selling_coupons / validate_coupon), is_change_payment_method_enabled (update_invoice_payment_methods), tip_amount, dining_guest_count, combo_options, pay_zero_amount. The session user's own row = the switches that apply to MCP orders.","response":{"description":"{ status: true, message: \"users list\", data: users[] }. No parameters; branch = session branch. Not paginated. Side effect: users without a unique PIN get one written to the DB. On a DB error the call does not answer cleanly. 401 when not authenticated.","fields":{"status":"boolean. true.","message":"string. \"users list\".","data":"array. POS users of the session branch (location_id = session branch) PLUS every multi-location user of the tenant (is_multi_location_allowed = 1), admins included. Not paginated.","data[].id":"int. User id. USE: created_by_user_id / close_by_user_id in sync_invoice, user_id in log_invoice_activities, close_by_user_id in get_today_sales.","data[].username":"string. Display name. USE: shown in order details (created by / closed by) and log_invoice_activities username.","data[].email":"string. Login email. USE: sync_invoice user = cashier email.","data[].pin":"string. 4-digit personal PIN (auto-assigned when missing or duplicated). SENSITIVE: never show or echo it. NOT USED by web-pos.","data[].role":"string. \"admin\" | \"staff\" | …  USE: log_invoice_activities role. Only role admin may save settings (update_user_permissions).","data[].role_id":"int | null. Role id (customer roles).","data[].location_id":"int. Home branch.","data[].thumbnail":"string. Avatar URL or \"\".","data[].is_multi_location_allowed":"0 | 1. May switch branches (duplicated in setting).","data[].is_master_locations_enabled":"0 | 1. Master-location tenant (duplicated in setting).","data[].ios_modules":"object. The user's ios_modules flags (same keys as get_user_extras data.ios_modules, WITHOUT is_activities_enabled).","data[].device_info":"array. Registered POS devices of this user. [] = none.","data[].device_info[].id":"int. Device row id.","data[].device_info[].device_id":"string. Device id.","data[].device_info[].device_name":"string. Device name.","data[].device_info[].app_os_version":"string | null. OS version.","data[].device_info[].app_version":"string | null. App version.","data[].device_info[].build_number":"string | null. App build.","data[].device_info[].user_id":"int. User id.","data[].device_info[].username":"string. User name.","data[].device_info[].customer_id":"int. Tenant id.","data[].device_info[].location_id":"int. Branch id.","data[].device_info[].last_online":"string. ISO date-time last seen.","data[].token":"string. A ready JWT for this user (Android fast user switch). SENSITIVE: never show, store or use it. NOT USED by web-pos.","data[].setting":"object. The user's POS switches (users columns + the positional permissions string). Same object as the login user.setting that web-pos keeps in redux appSettings. Change with update_user_permissions (session user only).","data[].setting.is_multi_location_allowed":"0 | 1. User may switch branches (web-pos shows the branch picker after login). Dashboard-managed.","data[].setting.is_master_locations_enabled":"0 | 1. Tenant is master-location.","data[].setting.print_receipt":"0 | 1. Auto print receipt. Printing only.","data[].setting.print_no_of_times":"int. Receipt copies. Printing only.","data[].setting.is_ingredient_disabled":"0 | 1. Hide ingredients. NOT USED by web-pos.","data[].setting.kds_auto_receipt":"0 | 1. Auto print KDS ticket. Printing only.","data[].setting.invoice_auto_receipt":"0 | 1. Auto print invoice. Printing only.","data[].setting.cart_options":"0 | 1. Cart options menu. UI only.","data[].setting.print_and_save":"0 | 1. Print after save. Printing only.","data[].setting.cloud_print":"0 | 1. Cloud print. Printing only.","data[].setting.quit_printers_with_cloud_enabled":"0 | 1. Printing only.","data[].setting.invoice_printer_act_as_kds":"0 | 1. Printing only.","data[].setting.cart_note":"0 | 1. Order note field. UI only.","data[].setting.cart_car_plate":"0 | 1. Car-plate field. UI only.","data[].setting.token_number":"0 | 1. Token number on receipts. Printing only.","data[].setting.discount_codes":"0 | 1. USE: 1 = discount-code list (get_user_extras discount_code) AND coupon entry (list_selling_coupons / validate_coupon) are offered; 0 = both hidden. Missing → off.","data[].setting.transfer_order_pincode":"0 | 1. PIN before transferring an order. UI only.","data[].setting.daily_sale_input_check":"0 | 1. Daily sales input check. NOT USED by web-pos.","data[].setting.daily_sale_media_check":"0 | 1. NOT USED by web-pos.","data[].setting.active_pincode":"0 | 1. PIN lock. UI only.","data[].setting.label_printing":"0 | 1. Printing only.","data[].setting.qrcode_on_label_print":"0 | 1. Printing only.","data[].setting.is_kds_device":"0 | 1. This till prints KDS tickets. Printing only.","data[].setting.open_drawer":"0 | 1. Open cash drawer. Hardware only.","data[].setting.user_card_digits":"0 | 1. Ask last card digits. UI only.","data[].setting.invoice_qrcode":"0 | 1. QR on invoice. Printing only.","data[].setting.force_adons":"0 | 1. Force add-on popup. UI only.","data[].setting.combo_options":"0 | 1 | absent. USE: 0 = fixed-price combos (combo product's own price, is_combo 1); 1 = per-child pricing (Σ chosen children combo_price, is_combo 2). Absent when the stored permission list is short (treat as 0).","data[].setting.ensure_combo_minimum_item_requirement":"0 | 1 | absent. USE: 1 = every combo bucket must be filled.","data[].setting.row_discount":"0 | 1. Per-line discount allowed. NOT USED by web-pos.","data[].setting.sms_invoice":"0 | 1. NOT USED by web-pos.","data[].setting.language":"string. App language code (\"\" / \"en\" / \"ar\"). NOT USED by web-pos.","data[].setting.zebra_ip":"string. Label printer IP. Printing only.","data[].setting.zebra_port":"int. Label printer port. Printing only.","data[].setting.offline_order_sync":"0 | 1. UI only.","data[].setting.orderhistory_print_receipt":"0 | 1. Printing only.","data[].setting.fraction_digits":"int. Qty fraction digits (Android). NOT USED for money (use get_user_extras decimal_points).","data[].setting.brand_id":"int. User's default brand (0 = none). USE: web-pos pre-selects this brand.","data[].setting.dining_guest_count":"0 | 1. USE: 1 = ask the number of guests on the first save of a dine-in order (sync_invoice dining_guests).","data[].setting.is_brand_groups":"0 | 1. Brand groups UI.","data[].setting.is_extras_enble":"0 | 1. Extras (materials) UI. Out of scope.","data[].setting.display_modifier_titles":"0 | 1. UI only.","data[].setting.modifier_sort_min_required_desc":"0 | 1. Modifier sort. UI only.","data[].setting.print_only_save_kds_orders":"0 | 1. Printing only.","data[].setting.print_note_on_receipt":"0 | 1. Printing only.","data[].setting.show_receipt_screen":"0 | 1. UI only.","data[].setting.order_type":"int. Default order type toggle. UI only.","data[].setting.open_dine_in":"0 | 1. UI only.","data[].setting.hide_cart_pay_button":"0 | 1. UI only.","data[].setting.delivery_detail_enable":"0 | 1. Delivery details form. UI only.","data[].setting.is_attributed_receipt":"0 | 1. Printing only.","data[].setting.is_cds_enable":"0 | 1. Customer display. NOT USED by agents.","data[].setting.external_order_print_receipt":"0 | 1. Printing only.","data[].setting.text_receipt":"0 | 1. Printing only.","data[].setting.each_category_kds_print":"0 | 1. Printing only.","data[].setting.exchange_format":"int. NOT USED by web-pos.","data[].setting.has_void_order_permission":"0 | 1. Void permission (Android). NOT USED by web-pos.","data[].setting.hide_clear_checkout":"0 | 1. UI only.","data[].setting.kds_instruction":"0 | 1. Printing only.","data[].setting.pay_default_usd":"0 | 1. NOT USED by web-pos.","data[].setting.rpt_business_logo":"0 | 1. Receipt option. Printing only.","data[].setting.rpt_order_type":"0 | 1. Printing only.","data[].setting.rpt_is_modified":"0 | 1. Printing only.","data[].setting.rpt_biller_info":"0 | 1. Printing only.","data[].setting.rpt_total_before_vat":"0 | 1. Printing only.","data[].setting.rpt_vat_info":"0 | 1. Printing only.","data[].setting.rpt_net_total":"0 | 1. Printing only.","data[].setting.rpt_exchange_rate":"0 | 1. Printing only.","data[].setting.rpt_dine_guest_count":"0 | 1. Printing only.","data[].setting.rpt_paid_at":"0 | 1. Printing only.","data[].setting.esc_command":"0 | 1. Printing only.","data[].setting.tip_amount":"0 | 1. USE: 1 = tips on: an overpay on a non-cash tender may be taken as a tip (sync_invoice tip_amount); 0 = overpay is change.","data[].setting.print_void_orders":"0 | 1. Printing only.","data[].setting.qr_code_on_print_receipt":"0 | 1. Printing only.","data[].setting.is_channels_enable":"0 | 1. USE: 1 = channels on: web-pos selects channel_id (else the first of list_selling_channels) and discount codes honour channel_ids; 0 = no channel (\"Main\").","data[].setting.channel_id":"int. USE: default channel (list_selling_channels channel_id; 0 = first). Saved by web-pos Settings → Channel.","data[].setting.unify_brands":"0 | 1. Show all brands' products together. UI only.","data[].setting.ingredient_stay_standalone":"0 | 1. NOT USED by web-pos.","data[].setting.allegy_item":"0 | 1. Allergy item flag (server spelling). NOT USED by web-pos.","data[].setting.dinein_serving_role":"0 | 1. NOT USED by web-pos.","data[].setting.draft_order":"0 | 1. NOT USED by web-pos.","data[].setting.is_kds_expeditor":"0 | 1. KDS expeditor. NOT USED by agents.","data[].setting.is_qr_payment":"0 | 1. NOT USED by web-pos.","data[].setting.pay_zero_amount":"0 | 1. USE: 1 = a zero-payable order completes with { cash: 0 } without the payment step.","data[].setting.print_deleted_item_on_receipt":"0 | 1. Printing only.","data[].setting.receipt_format":"0 | 1. Receipt template. Printing only.","data[].setting.receipt_translation":"0 | 1. Printing only.","data[].setting.product_translation":"0 | 1. UI only.","data[].setting.category_translation":"0 | 1. UI only.","data[].setting.print_invoice_translation":"0 | 1. Printing only.","data[].setting.print_kds_translation":"0 | 1. Printing only.","data[].setting.reservation_with_dine_in":"0 | 1. NOT USED by web-pos.","data[].setting.splitefire":"0 | 1. KDS split fire (server spelling). NOT USED by agents.","data[].setting.station_id":"int. KDS station. NOT USED by agents.","data[].setting.cloud_target_user_id":"int | null. Cloud-print target user. Printing only.","data[].setting.cloud_target_device_id":"string | null. Cloud-print target device. Printing only.","data[].setting.one_table_one_order":"0 | 1. One open order per table (Android). NOT USED by web-pos.","data[].setting.is_socket_server":"0 | 1. This device is the peer socket server. NOT USED by agents.","data[].setting.is_client":"0 | 1. Peer socket client. NOT USED by agents.","data[].setting.exchange_rate":"number. Branch exchange_rate config (0 when unset). NOT USED by web-pos checkout.","data[].setting.currency":"object | undefined. Currency config row { id, name, value, customer_id, location_id } of the caller's branch; absent when none. Prefer get_pos_configs currency.","data[].setting.currency.id":"int. Config row id.","data[].setting.currency.name":"string. \"currency\".","data[].setting.currency.value":"string. Currency code; \"\" = not set (AED).","data[].setting.currency.customer_id":"int. Tenant id.","data[].setting.currency.location_id":"int. Branch id.","data[].setting.web_local_network_printer":"0 | 1. Printing only.","data[].setting.open_price_pincode":"0 | 1. PIN before open price. UI only.","data[].setting.is_transfer_enabled":"0 | 1. Transfer-order button. UI only.","data[].setting.block_print_until_order_open":"0 | 1. Printing only.","data[].setting.user_product_sales_counter":"0 | 1. Product sold counter screen. UI only.","data[].setting.is_change_payment_method_enabled":"0 | 1. USE: 1 = 'Change payment method' is offered on paid orders (update_invoice_payment_methods); 0 = hidden.","data[].setting.skip_zero_quantity_items":"0 | 1. Printing only.","data[].setting.skip_zero_amount_items":"0 | 1. Printing only.","data[].setting.settle_wallet_topup":"0 | 1. NOT USED by web-pos.","data[].setting.is_shift_management":"0 | 1. Shift fallback switch (shift tools out of scope).","data[].setting.acknowledge_dine_table_edit":"0 | 1. UI only.","data[].setting.lock_screen":"int. Lock-screen timeout. UI only.","data[].setting.is_soldout":"0 | 1. USE: 1 = the user may mark products sold out / available (update_product_availability); 0 = no.","data[].setting.force_crm_user":"0 | 1. Customer required on orders (Android). NOT USED by web-pos.","data[].setting.shift_transactions":"0 | 1. Shift history (out of scope).","data[].setting.eod_shift_report":"0 | 1. End-of-day card (out of scope).","data[].setting.shift_z_report":"0 | 1. Z report (out of scope).","data[].setting.view_estimated_cash":"0 | 1. Estimated cash (out of scope).","data[].setting.external_order_ready":"0 | 1. Aggregator 'ready' push. NOT USED by agents.","data[].setting.override_shift":"0 | 1. Returned by the dev1 build only. Shift (out of scope).","data[].shift_z_report":"0 | 1. dev1 build leaves this copy at top level too (same as setting.shift_z_report).","data[].view_estimated_cash":"0 | 1. dev1 top-level copy of setting.view_estimated_cash.","data[].external_order_ready":"0 | 1. dev1 top-level copy of setting.external_order_ready."},"master_rules":["web-pos: cached at boot in IndexedDB 'users'. Read for: order details / receipts (created_by_user_id / close_by_user_id → username), the daily-sales user filter (close_by_user_id), the Settings page (the logged-in user's row → setting toggles) and the Settings user switcher.","User switch in web-pos = pick a user from this list and re-login with email + PASSWORD. It does not use pin or token. Agents never switch users: MCP always acts as the API key user.","The settings that apply to MCP actions are the SESSION user's row (id of the API key user). web-pos reads them from the login response user.setting, which has the same shape.","Setting → feature (web-pos): is_channels_enable / channel_id → list_selling_channels; is_soldout → update_product_availability; discount_codes → get_user_extras discount_code[] + list_selling_coupons / validate_coupon; is_change_payment_method_enabled → update_invoice_payment_methods; tip_amount → tip on overpay (sync_invoice tip_amount); dining_guest_count → ask guests for dine-in (sync_invoice dining_guests); combo_options / ensure_combo_minimum_item_requirement → combo pricing; pay_zero_amount → zero orders skip payment.","Loyalty is NOT a user setting: get_user_extras ios_modules.is_loyalty_enable."],"caveats":"pin and token are returned in clear for every user — never display, store or forward them. The dev1 build also returns shift_z_report / view_estimated_cash / external_order_ready at top level and setting.override_shift. combo_options / ensure_combo_minimum_item_requirement are missing when the stored permission string is shorter than 24 / 25 entries.","example":{"status":true,"message":"users list","data":[{"id":4734,"username":"Cashier One","email":"cashier@example.com","pin":"1234","role":"admin","role_id":31,"location_id":907,"thumbnail":"","is_multi_location_allowed":0,"is_master_locations_enabled":1,"shift_z_report":0,"view_estimated_cash":0,"external_order_ready":0,"ios_modules":{"is_kds_enable":1,"is_daily_sale_report_enable":1,"is_order_hisotry_screen_enable":1,"is_sms_enable":1,"is_cloud_print_enable":1,"is_user_blocked":0,"is_branding_enable":1,"is_actual_price":1,"is_usd_base":0,"is_dine_in_enable":1,"is_wastage_enable":1,"is_material_stock_enable":1,"is_balance_enable":1,"is_product_scanner_enable":1,"is_bar_scanner_enable":1,"is_devices_enable":1,"is_peer_enable":1,"is_setting_enable":1,"is_loyalty_enable":1,"is_ticket_enabled":0,"is_wallet_enabled":1,"is_location_service_enabled":1,"is_crm_mobile_mandatory":0,"reload_dine_order":1,"order_dispatch_feature":0},"device_info":[{"id":123,"device_id":"device-0001","device_name":"Front iPad","app_os_version":null,"app_version":null,"build_number":null,"user_id":4734,"username":"Cashier One","customer_id":437,"location_id":907,"last_online":"2026-09-18T13:19:53.000Z"}],"token":"<jwt>","setting":{"is_multi_location_allowed":0,"is_master_locations_enabled":1,"print_receipt":1,"print_no_of_times":1,"is_ingredient_disabled":0,"kds_auto_receipt":1,"invoice_auto_receipt":1,"cart_options":1,"print_and_save":1,"cloud_print":1,"quit_printers_with_cloud_enabled":1,"invoice_printer_act_as_kds":1,"cart_note":1,"cart_car_plate":1,"token_number":1,"discount_codes":1,"transfer_order_pincode":1,"daily_sale_input_check":1,"daily_sale_media_check":1,"active_pincode":1,"label_printing":1,"qrcode_on_label_print":1,"is_kds_device":1,"open_drawer":1,"user_card_digits":1,"invoice_qrcode":1,"force_adons":1,"combo_options":1,"ensure_combo_minimum_item_requirement":1,"row_discount":0,"sms_invoice":0,"language":"","zebra_ip":"","zebra_port":0,"offline_order_sync":0,"orderhistory_print_receipt":0,"fraction_digits":2,"brand_id":0,"dining_guest_count":1,"is_brand_groups":0,"is_extras_enble":0,"display_modifier_titles":0,"modifier_sort_min_required_desc":1,"print_only_save_kds_orders":0,"print_note_on_receipt":0,"show_receipt_screen":0,"order_type":0,"open_dine_in":0,"hide_cart_pay_button":0,"delivery_detail_enable":0,"is_attributed_receipt":0,"is_cds_enable":0,"external_order_print_receipt":0,"text_receipt":0,"each_category_kds_print":0,"exchange_format":0,"has_void_order_permission":0,"hide_clear_checkout":1,"kds_instruction":1,"pay_default_usd":0,"rpt_business_logo":0,"rpt_order_type":0,"rpt_is_modified":0,"rpt_biller_info":0,"rpt_total_before_vat":0,"rpt_vat_info":0,"rpt_net_total":0,"rpt_exchange_rate":0,"rpt_dine_guest_count":0,"rpt_paid_at":0,"esc_command":0,"tip_amount":0,"print_void_orders":0,"qr_code_on_print_receipt":0,"is_channels_enable":0,"channel_id":463,"unify_brands":0,"ingredient_stay_standalone":0,"allegy_item":0,"dinein_serving_role":0,"draft_order":0,"is_kds_expeditor":0,"is_qr_payment":0,"pay_zero_amount":0,"print_deleted_item_on_receipt":0,"receipt_format":0,"receipt_translation":0,"product_translation":0,"category_translation":0,"print_invoice_translation":0,"print_kds_translation":0,"reservation_with_dine_in":0,"splitefire":0,"station_id":0,"cloud_target_user_id":0,"cloud_target_device_id":null,"one_table_one_order":0,"is_socket_server":0,"is_client":0,"exchange_rate":12,"currency":{"id":10103,"name":"currency","value":"","customer_id":437,"location_id":907},"web_local_network_printer":0,"open_price_pincode":0,"is_transfer_enabled":0,"block_print_until_order_open":0,"user_product_sales_counter":0,"is_change_payment_method_enabled":0,"skip_zero_quantity_items":0,"skip_zero_amount_items":0,"settle_wallet_topup":1,"is_shift_management":0,"acknowledge_dine_table_edit":0,"lock_screen":0,"is_soldout":0,"force_crm_user":0,"shift_transactions":0,"eod_shift_report":0,"override_shift":0}}]}},"parameters":{"type":"object","properties":{},"required":[]},"module":"pos_session","safety":"read"},{"name":"list_selling_channels","description":"GET /api/v2/get-channels. Why: Sales channels offered at the cashier's branch — what web-pos loads at boot and shows in Settings → Channel. The channel_id is the key for per-channel product prices (list_selling_products data.products[].price_levels[].channel_id), for discount-code channel scope (channel_ids) and for an order's channel_id/channel_name. Not paginated. Management (description, branches, create/update/delete): list_channels / get_channel in the channels catalog. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_selling_channels\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/get-channels","why":"Sales channels offered at the cashier's branch — what web-pos loads at boot and shows in Settings → Channel. The channel_id is the key for per-channel product prices (list_selling_products data.products[].price_levels[].channel_id), for discount-code channel scope (channel_ids) and for an order's channel_id/channel_name. Not paginated. Management (description, branches, create/update/delete): list_channels / get_channel in the channels catalog.","response":{"description":"{ status, message, data }. data = channels assigned to the branch (channels_locations or legacy channels.location_id), DISTINCT, ordered by name. Only id + name — description/sku/branches come from list_channels. Error: HTTP 500 { status: false, message: \"Database error\", data: [] }; 401 when not authenticated.","fields":{"status":"boolean. true on success.","message":"string. \"Channels data found\" (also when data is empty).","data":"array. Channels of the branch. [] = none (then the POS works without channels).","data[].channel_id":"int. Channel id. USE: the selected channel's id — matches price_levels[].channel_id, discount channel_ids and order channel_id. web-pos stores it as the IndexedDB key (id = channel_id).","data[].channel_name":"string. Channel name. USE: shown in the channel picker (searchable) and the order's channel_name when a channel is put on an order."},"master_rules":["Channels are active only when the POS user's setting is_channels_enable = 1 (user setting from login / update-user-permissions). Otherwise web-pos has NO selected channel and shows the synthetic default \"Main\".","Selected channel (web-pos): the user's setting channel_id if it is in this list; if the setting is 0 or not found, the FIRST row (alphabetical). Picking a channel in Settings saves it as the user's default channel_id.","Orders: web-pos puts channel_id 0 and channel_name \"\" on every new order (checkout, new/table order, transfer) — the selected channel is NOT sent; adding items to an existing order keeps that order's channel. Do the same for POS parity. If you do tag an order, send channel_id + channel_name from THIS list; the server stores them verbatim (no validation, no repricing).","Prices: web-pos does NOT apply channel prices — it always charges the product's actual_price. A channel's own price is list_selling_products data.products[].price_levels[] where channel_id matches → channel_actual_price (used by aggregator menu syncs). No match → actual_price.","Discount codes: when is_channels_enable = 1 and a code's channel_ids (csv) is non-empty, the code is valid only if the selected channel_id is in it (no selected channel → the user's setting channel_id, else 0). Empty channel_ids or channels disabled → valid on any channel. Codes come from get_user_extras data.discount_code[] (managed with list_discounts). Coupons have no channel scope.","KDS and receipts: web-pos does not print or send the channel to the KDS."],"caveats":"Returns channels of the session/given branch only, not the whole tenant. On dev data no product has price_levels, so channel prices are usually empty.","example":{"status":true,"message":"Channels data found","data":[{"channel_id":463,"channel_name":"Coffee Bar"},{"channel_id":509,"channel_name":"Express"},{"channel_id":482,"channel_name":"Food Panda"}]}},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Optional."}},"required":[]},"module":"pos_session","safety":"read"},{"name":"get_master_data","description":"GET /api/v2/master-data. Why: Raw table dump used by the POS apps for offline sync. Useful rows: table=locations (id + name + integration ids of the branches the session USER may open — web-pos branch picker) and table=user (the session user's discount limit). table=product_materials / materials are for NON-master (inventory-per-branch) tenants and out of scope; table=products is a multi-MB raw dump (use list_selling_products); table=categories is broken. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_master_data\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/master-data","why":"Raw table dump used by the POS apps for offline sync. Useful rows: table=locations (id + name + integration ids of the branches the session USER may open — web-pos branch picker) and table=user (the session user's discount limit). table=product_materials / materials are for NON-master (inventory-per-branch) tenants and out of scope; table=products is a multi-MB raw dump (use list_selling_products); table=categories is broken.","response":{"description":"A BARE ARRAY of raw rows (select *; for user a 6-column subset). No envelope. Errors are not reported: an unknown/missing table or a SQL error returns an EMPTY body (the tool gets nothing). 401 when not authenticated.","fields":{"[]":"array. Rows of the requested table. [] when nothing matches.","[].id":"(table=locations) int. Branch id (= session location_id).","[].name":"(table=locations) string. Branch name.","[].customer_id":"(table=locations) int. Tenant id.","[].is_ware_house":"(table=locations) string | null. \"1\" = also a warehouse. NOT USED by web-pos.","[].is_primary":"(table=locations) string | null. Primary branch marker. NOT USED by web-pos.","[].otter_location_id":"(table=locations) string | null. Otter integration id. NOT USED by web-pos.","[].deliverect_location_id":"(table=locations) string | null. Deliverect id. NOT USED by web-pos.","[].enable_menu_checkout":"(table=locations) 0 | 1 | null. Online menu checkout on. NOT USED by web-pos.","[].invoice_service_version":"(table=locations) string. Invoice pipeline version (\"v1\"). NOT USED by web-pos.","[].payment_provider":"(table=locations) string | null. Online payment provider. NOT USED by web-pos.","[].ngenius_terminal_id":"(table=locations) string | null. N-Genius card terminal id. USE: web-pos card-terminal flow needs this AND ngenius_merchant_id (out of scope for agents).","[].ngenius_merchant_id":"(table=locations) string | null. N-Genius merchant id (see ngenius_terminal_id).","[].enable_grubtech":"(table=locations) 0 | 1. Grubtech integration on. NOT USED by web-pos.","[].allow_grubtech_status_updates":"(table=locations) 0 | 1. Push status to Grubtech. NOT USED by web-pos.","[].enable_limetray":"(table=locations) 0 | 1. Limetray integration on (column present in the backend DB; not returned by dev1). NOT USED by web-pos.","[].allow_limetray_status_updates":"(table=locations) 0 | 1. Push status to Limetray (same note). NOT USED by web-pos.","[].username":"(table=user) string. Session user's display name.","[].role":"(table=user) string. Session user's role.","[].can_preview_invoice":"(table=user) 0 | 1. Same as get_user_extras.","[].max_discount_permit":"(table=user) int. Max manual discount % (same as get_user_extras).","[].location_id":"(table=user) int. Session user's home branch.","[].category_id":"(table=products) int. Raw products row: see list_selling_products for the meaning of every product column."},"master_rules":["web-pos: the branch picker (LocationSelectorDialog) lists table=locations and switches branch by regenerating the token; at boot it also loads table=product_materials (unused on master tenants).","table=locations follows the USER's allow_locations, not the API key's: MCP does not filter this payload. For the branches this key may use, call list_locations.","For products, prices and availability use list_selling_products; for the session branch row use get_user_location."],"caveats":"table=categories always fails (the SQL misses an AND) and returns an empty body. Rows are raw DB rows: string flags such as is_ware_house \"1\" stay strings.","example":[{"id":907,"name":"Default location","customer_id":437,"is_ware_house":"1","is_primary":null,"otter_location_id":null,"deliverect_location_id":null,"enable_menu_checkout":null,"invoice_service_version":"v1","payment_provider":null,"ngenius_terminal_id":null,"ngenius_merchant_id":null,"enable_grubtech":0,"allow_grubtech_status_updates":0},{"id":908,"name":"Johar Town","customer_id":437,"is_ware_house":"1","is_primary":null,"otter_location_id":null,"deliverect_location_id":null,"enable_menu_checkout":null,"invoice_service_version":"v1","payment_provider":null,"ngenius_terminal_id":null,"ngenius_merchant_id":null,"enable_grubtech":0,"allow_grubtech_status_updates":0}]},"parameters":{"type":"object","properties":{"table":{"type":"string","enum":["locations","user","products","product_materials","materials","categories"],"description":"Required."},"location-only":{"type":"string","description":"Optional."}},"required":["table"]},"module":"pos_session","safety":"read"},{"name":"get_today_sales","description":"GET /api/v2/today-sales-v3. Why: The web-pos Daily Sales (Z-style) report of one branch and business day: net sales, orders, VAT, tenders, order types, items, discounts, voids, open orders and per-user totals. Business day = opening_hour → next opening_hour in the branch time_zone (get_pos_configs). Filter by cashier with close_by_user_id (list_pos_users). Order-level drill-down: list_orders_history; audit trail: get_invoice_activities. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_today_sales\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/today-sales-v3","why":"The web-pos Daily Sales (Z-style) report of one branch and business day: net sales, orders, VAT, tenders, order types, items, discounts, voids, open orders and per-user totals. Business day = opening_hour → next opening_hour in the branch time_zone (get_pos_configs). Filter by cashier with close_by_user_id (list_pos_users). Order-level drill-down: list_orders_history; audit trail: get_invoice_activities.","response":{"description":"A BARE object (no status envelope). Only COMPLETED orders count as sales; open orders are 'unpaid', cancelled are 'void'. Window per date: date + opening_hour in the branch time_zone → +24 h (UTC). Errors: 400 { status:false, message:\"Invalid date parameter(s): … Use DD-MM-YYYY.\" }, 400 { message: \"Missing Time Zone or Business Hours\" }, 400 \"Invalid shift_id query parameter\", 403 \"Access denied for this location\", 401 when not authenticated.","fields":{"order_type":"object. { <order type title>: number of COMPLETED orders, …, void_orders, refunded_items }. \"dine-in\" becomes dine_in and \"drive-thru\" drive_thru.","order_type.void_orders":"int. Orders cancelled in the window.","order_type.refunded_items":"int. Refunded lines on completed orders.","dining_guests":"int. Σ dining_guests of completed orders.","item_transferred":"int. Lines transferred to another order.","items":"array. Items sold on completed orders, grouped by product (transferred and refunded lines excluded).","items[].type":"string. Product type / category label.","items[].name":"string. Product name.","items[].unit_id":"int. Unit id.","items[].extras":"string | null. Extras text.","items[].is_addon":"0 | 1. Add-on product.","items[].is_refunded":"0 | 1. Refund row.","items[].total":"number. Money sold.","items[].qty":"number. Quantity sold.","items[].period":"string. ISO time of the report (placeholder, not a sale time).","items[].list":"array. Extras of the lines (raw).","items[].extra":"array. Extra rows (raw).","addons":"array. Same shape as items, for add-on lines.","refund_items":"array. Same shape as items, for refunded lines.","modifiers":"array. Modifier options sold (name, qty, total).","itemsCat":"array. { type, total } per product type, then synthetic rows Modifiers, Add-ons, Refund Items.","itemsCat[].type":"string. Type label.","itemsCat[].total":"number. Money.","itemsPayment":"array. One row per tender name (lower-case) of completed orders, then synthetic rows \"Unpaid Order\", \"Discount Amount\", \"Tip Amount\", \"Points\" and (when any) \"Top-ups (Cash & Card)\".","itemsPayment[].payment_method":"string. Tender name (list_selling_payment_methods) or a synthetic label.","itemsPayment[].total":"number. Money.","itemsPayment[].transactions":"int. Number of orders / payments.","itemsPayment[].tip_amount":"number. Tips paid with this tender (tender rows only).","totalAmount":"number. Σ payable of completed orders (+ late closures).","total_sales":"number. Same as totalAmount. USE: net sales headline.","total_orders":"int. Completed orders (+ late closures).","hourly":"array. { hour (0-23 branch time), value, order_count } of completed orders.","hourly[].hour":"int. Hour in the branch time zone.","hourly[].value":"number. Payable in that hour.","hourly[].order_count":"int. Orders in that hour.","time_distribution":"array. Same as hourly.","opening_hour":"string | null. \"HH:mm\" business-day start used.","time_zone":"string. Branch UTC offset used, e.g. \"+05:00\".","location_id":"int. Branch reported.","wallet_orders":"object. Orders paid (partly) with the customer wallet.","wallet_orders.total_amount":"number. Their payable.","wallet_orders.total_count":"int. Their count.","wallet_topups":"object. Wallet top-ups sold in the window.","wallet_topups.transaction_type":"string. \"debit\".","wallet_topups.amount":"number. Top-up money.","wallet_topups.count":"int. Top-ups.","avg_invoice_bill":"number. total_sales / total_orders (not rounded).","avg_per_order":"number. Same, rounded to 2.","avg_per_guest":"number. total_sales / guests (dine-in guests, 1 per other order).","total_vat":"number. Σ vat of completed orders.","void_orders":"object. Cancelled orders summary.","void_orders.title":"string. \"Void orders\".","void_orders.no_of_cancelled_order":"int. Cancelled orders.","void_orders.no_of_canceled_order":"int. Same (legacy spelling).","void_orders.no_of_items_refund":"int. Refunded payments.","void_orders.total":"number. Money of cancelled orders.","void_orders.transaction":"array. { payment_method, total, transactions } per tender of cancelled orders.","void_orders.transaction[].payment_method":"string. Tender.","void_orders.transaction[].total":"number. Money.","void_orders.transaction[].transactions":"int. Count.","ts":"int. Server time in ms.","time":"string. Current tenant time \"hh:mm:ss AM\".","withdraw_amount":"number. Cash withdrawals (cash out) in the window.","credit_amount":"number. Cash-ins in the window.","active_shift_id":"int | null. Shift filter applied (only with shift_id).","active_shift":"object | null. dev1 build only: that shift row (shift tools out of scope).","shift_filter_applied":"boolean. true when shift_id was sent.","discount_summary":"array. { discount_name, count, amount } per discount used.","discount_summary[].discount_name":"string. Discount name or \"Manual / Other\".","discount_summary[].count":"int. Uses.","discount_summary[].amount":"number. Money.","invoice_begin":"int | null. First receipt no. (customer_invoice_id) in the window.","invoice_end":"int | null. Last receipt no.","invoices_count":"int. All orders in the window (any status).","order_types":"array. { name, count, amount } per order type of completed orders, by amount desc.","order_types[].name":"string. Order type title.","order_types[].count":"int. Orders.","order_types[].amount":"number. Payable.","user_statistics":"object. Per-user breakdowns: open_orders, completed_orders, created_by_users, closed_by_users.","user_statistics.open_orders":"object. { users[], total_amount, total_orders } of OPEN (unpaid) orders by user.","user_statistics.completed_orders":"object. Same for completed orders.","user_statistics.created_by_users":"object. All orders by created_by_user_id (users[] also carry user_id).","user_statistics.closed_by_users":"object. Completed orders by close_by_user_id.","tip_amount":"object. { title \"Tip Amount\", total, transactions } of completed orders.","tip_amount.title":"string. \"Tip Amount\".","tip_amount.total":"number. Tips.","tip_amount.transactions":"int. Orders with a tip.","points":"object. { title \"Points\", total, transactions }: loyalty burn money.","points.title":"string. \"Points\".","points.total":"number. Burned points money.","points.transactions":"int. Orders.","discount_amount":"object. { title \"Discounts\", total, transactions }.","discount_amount.title":"string. \"Discounts\".","discount_amount.total":"number. Discount money.","discount_amount.transactions":"int. Orders with a discount.","unpaid_order":"object. { title \"Unpaid Order\", total, transactions } of OPEN orders.","unpaid_order.title":"string. \"Unpaid Order\".","unpaid_order.total":"number. Open payable.","unpaid_order.transactions":"int. Open orders.","pending_orders":"array. Open orders in the window.","pending_orders[].invoice_number":"int. Receipt no.","pending_orders[].order_type":"string. Order type.","pending_orders[].amount":"number. Payable.","pending_orders[].currency":"string. Currency code (\"AED\" default).","pending_orders[].created_date":"string. \"MMM D\" branch time.","pending_orders[].user":"string. Creator email.","late_closure":"array. Orders created BEFORE the window but paid inside it (counted in total_sales).","late_closure[].invoice_number":"int. Receipt no.","late_closure[].order_type":"string. Order type.","late_closure[].amount":"number. Payable.","late_closure[].currency":"string. Currency.","late_closure[].created_date":"string. \"MMM D\".","late_closure[].paid_date":"string. \"MMM D\".","total_without_vat":"number. total_payable − total_vat.","total_payable":"number. Σ payable of completed orders.","gross_sales":"number. total_payable + discounts.","order_charges":"object. Σ charges of completed orders.","order_charges.charges_fee":"number. Service charges.","order_charges.delivery_charges":"number. Delivery charges.","date_range":"object. Business dates reported.","date_range.from":"string. YYYY-MM-DD.","date_range.to":"string. YYYY-MM-DD.","total_customers":"object. { count } distinct CRM customers.","total_customers.count":"int. Customers.","total_discounts":"object. { total_amount, total_applications, references[] } per discount reference.","total_discounts.total_amount":"number. Discount money.","total_discounts.total_applications":"int. Uses.","total_discounts.references":"array. Per-discount detail rows.","complete_order_summary":"object. { rows[], total_discount_amount } per completed order with a discount.","complete_order_summary.rows":"array. Order rows.","complete_order_summary.total_discount_amount":"number. Discount money.","order_type_summary":"object. { rows[{ type_name, orders, share_percent, amount }], totals }.","order_type_summary.rows":"array. Per order type.","order_type_summary.totals":"object. { orders, share_percent, amount }.","order_discount_summary":"object. { rows[], totals { applied_times, avg_discount, total_amount }, footer { discount_percent_of_gross_sales, gross_sales_amount, gross_sales_currency, message } }.","order_discount_summary.rows":"array. Per order-level discount.","order_discount_summary.totals":"object. Totals.","order_discount_summary.footer":"object. Discount share of gross sales + a display message.","item_discount_summary":"object. { rows[], totals { qty, original, discount, net } } for line discounts.","item_discount_summary.rows":"array. Per discounted item.","item_discount_summary.totals":"object. Totals.","user_statistics.open_orders.users":"array. One row per user.","user_statistics.open_orders.users[].user":"string. User email.","user_statistics.open_orders.users[].total_amount":"number. Payable.","user_statistics.open_orders.users[].order_count":"int. Orders.","user_statistics.open_orders.users[].orders":"array. { id, uuid, payable, created_at (ISO UTC), order_type, dining_guests } per order; uuid → get_invoice_activities / list_orders_history.","user_statistics.open_orders.total_amount":"number. Group payable.","user_statistics.open_orders.total_orders":"int. Group orders.","user_statistics.completed_orders.users":"array. One row per user.","user_statistics.completed_orders.users[].user":"string. User email.","user_statistics.completed_orders.users[].total_amount":"number. Payable.","user_statistics.completed_orders.users[].order_count":"int. Orders.","user_statistics.completed_orders.users[].orders":"array. { id, uuid, payable, created_at (ISO UTC), order_type, dining_guests } per order; uuid → get_invoice_activities / list_orders_history.","user_statistics.completed_orders.total_amount":"number. Group payable.","user_statistics.completed_orders.total_orders":"int. Group orders.","user_statistics.created_by_users.users":"array. One row per user.","user_statistics.created_by_users.users[].user":"string. User email.","user_statistics.created_by_users.users[].user_id":"string. User id (list_pos_users id, as a string).","user_statistics.created_by_users.users[].total_amount":"number. Payable.","user_statistics.created_by_users.users[].order_count":"int. Orders.","user_statistics.created_by_users.users[].orders":"array. { id, uuid, payable, created_at (ISO UTC), order_type, dining_guests } per order; uuid → get_invoice_activities / list_orders_history.","user_statistics.created_by_users.total_amount":"number. Group payable.","user_statistics.created_by_users.total_orders":"int. Group orders.","user_statistics.closed_by_users.users":"array. One row per user.","user_statistics.closed_by_users.users[].user":"string. User email.","user_statistics.closed_by_users.users[].user_id":"string. User id (list_pos_users id, as a string).","user_statistics.closed_by_users.users[].total_amount":"number. Payable.","user_statistics.closed_by_users.users[].order_count":"int. Orders.","user_statistics.closed_by_users.users[].orders":"array. { id, uuid, payable, created_at (ISO UTC), order_type, dining_guests } per order; uuid → get_invoice_activities / list_orders_history.","user_statistics.closed_by_users.total_amount":"number. Group payable.","user_statistics.closed_by_users.total_orders":"int. Group orders."},"master_rules":["web-pos Daily Sales page (app/daily-sales/page.jsx) calls it with date = the picked day as dd-MM-yyyy and, when a user is picked from list_pos_users, close_by_user_id. It shows totals, tenders, order types, items and prints detailed / short summaries (VAT No# from get_user_extras business_config.vat_no).","Money is what sync_invoice stored (payable, vat, discounts are client-computed); total_sales = Σ payable of completed orders incl. late closures.","Order uuids in user_statistics → get_invoice_activities / list_orders_history."],"caveats":"The default day is the SERVER date, not the branch date. The old catalog said YYYY-MM-DD: wrong, the server only accepts DD-MM-YYYY. Response is large (items and per-user order lists grow with volume). The example below is a live dev1 range report (from_date=01-08-2026&to_date=24-09-2026) cut to one element per array.","example":{"order_type":{"Delivery":2,"Takeaway":1,"void_orders":5,"refunded_items":0},"dining_guests":2,"item_transferred":0,"items":[{"type":"Espresso-Based Drinks","name":"Flat White","unit_id":0,"extras":null,"is_addon":0,"is_refunded":0,"total":33,"qty":3,"period":"2026-09-24T13:37:06.484Z","list":[],"extra":[]}],"addons":[],"refund_items":[],"modifiers":[],"itemsCat":[{"type":"Espresso-Based Drinks","total":33}],"itemsPayment":[{"payment_method":"cash","total":22,"transactions":2,"tip_amount":0}],"totalAmount":33,"total_sales":33,"total_orders":3,"hourly":[{"hour":15,"value":11,"order_count":1}],"time_distribution":[{"hour":15,"value":11,"order_count":1}],"opening_hour":"10:00","time_zone":"+05:00","location_id":907,"wallet_orders":{"total_amount":0,"total_count":0},"wallet_topups":{"transaction_type":"debit","amount":0,"count":0},"avg_invoice_bill":11,"avg_per_order":11,"avg_per_guest":11,"total_vat":1.04,"void_orders":{"title":"Void orders","no_of_cancelled_order":5,"no_of_canceled_order":5,"no_of_items_refund":0,"total":424,"transaction":[{"payment_method":"cash","total":424,"transactions":4}]},"ts":1790257026487,"time":"06:37:06 PM","withdraw_amount":0,"credit_amount":0,"active_shift_id":null,"active_shift":null,"shift_filter_applied":false,"discount_summary":[],"invoice_begin":16,"invoice_end":24,"invoices_count":9,"order_types":[{"name":"Delivery","count":2,"amount":22}],"user_statistics":{"open_orders":{"users":[{"user":"cashier@example.com","total_amount":31,"order_count":1,"orders":[{"id":8085857,"uuid":"b7b056e0-3c5f-437d-9258-968b6cc34a6e","payable":31,"created_at":"2026-08-26T10:03:08.000Z","order_type":"Dine In","dining_guests":2}]}],"total_amount":31,"total_orders":1},"completed_orders":{"users":[{"user":"cashier@example.com","total_amount":33,"order_count":3,"orders":[{"id":8085852,"uuid":"f5bd46f3-377a-477d-b748-876e9aab0063","payable":11,"created_at":"2026-08-20T11:07:42.000Z","order_type":"Delivery","dining_guests":1}]}],"total_amount":33,"total_orders":3},"created_by_users":{"users":[{"user":"cashier@example.com","user_id":"4734","total_amount":499,"order_count":9,"orders":[{"id":8085848,"uuid":"fdf751c3-f3f8-4cbe-9af0-76fd0d8482c8","payable":106,"created_at":"2026-08-17T06:25:20.000Z","order_type":"takeaway","dining_guests":null}]}],"total_amount":499,"total_orders":9},"closed_by_users":{"users":[{"user":"cashier@example.com","user_id":"4734","total_amount":33,"order_count":3,"orders":[{"id":8085852,"uuid":"f5bd46f3-377a-477d-b748-876e9aab0063","payable":11,"created_at":"2026-08-20T11:07:42.000Z","order_type":"Delivery","dining_guests":1}]}],"total_amount":33,"total_orders":3}},"tip_amount":{"title":"Tip Amount","total":0,"transactions":0},"points":{"title":"Points","total":0,"transactions":0},"discount_amount":{"title":"Discounts","total":0,"transactions":0},"unpaid_order":{"title":"Unpaid Order","total":31,"transactions":1},"pending_orders":[{"invoice_number":23,"order_type":"Dine In","amount":31,"currency":"AED","created_date":"Aug 26","user":"cashier@example.com"}],"late_closure":[],"total_without_vat":31.96,"total_payable":33,"gross_sales":33,"order_charges":{"charges_fee":0,"delivery_charges":0},"date_range":{"from":"2026-08-01","to":"2026-09-24"},"total_customers":{"count":1},"total_discounts":{"total_amount":0,"total_applications":0,"references":[]},"complete_order_summary":{"rows":[],"total_discount_amount":0},"order_type_summary":{"rows":[{"type_name":"Delivery","orders":2,"share_percent":66.67,"amount":22}],"totals":{"orders":3,"share_percent":100,"amount":33}},"order_discount_summary":{"rows":[],"totals":{"applied_times":0,"avg_discount":0,"total_amount":0},"footer":{"discount_percent_of_gross_sales":0,"gross_sales_amount":31.96,"gross_sales_currency":"AED","message":"Discounts represent 0% of gross sales (31.96 AED) · Deducted from net collected"}},"item_discount_summary":{"rows":[],"totals":{"qty":0,"original":0,"discount":0,"net":0}}}},"parameters":{"type":"object","properties":{"date":{"type":"string","description":"Optional."},"from_date":{"type":"string","description":"Optional."},"to_date":{"type":"string","description":"Optional."},"location_id":{"type":"string","description":"Optional."},"close_by_user_id":{"type":"string","description":"Optional."},"paid_at":{"type":"string","description":"Optional."},"brand_id":{"type":"string","description":"Optional."},"brands":{"type":"string","description":"Optional."},"shift_id":{"type":"string","description":"Optional."}},"required":[]},"module":"pos_session","safety":"read"},{"name":"get_invoice_activities","description":"GET /api/v2/activities/:uuid. Why: Audit trail of one order by uuid — what web-pos shows in the order's \"Activity\" section: who did what and when (created, items added/removed, paid, voided, payment method changed…). uuid comes from list_orders_history or list_dine_orders. Rows are written by the POS apps; cancel_invoice / refund_invoice done by an agent do NOT add rows. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_invoice_activities\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/activities/:uuid","why":"Audit trail of one order by uuid — what web-pos shows in the order's \"Activity\" section: who did what and when (created, items added/removed, paid, voided, payment method changed…). uuid comes from list_orders_history or list_dine_orders. Rows are written by the POS apps; cancel_invoice / refund_invoice done by an agent do NOT add rows.","response":{"description":"{ status: true, message, data: activity[] } — one row per activity identifier (latest version), newest first. data = [] when nothing was logged. Row keys are what the client sent (extra keys pass through).","fields":{"status":"boolean. Always true. On a server (database) error the request gets no answer and the tool times out.","message":"string. \"Activities found.\" | \"No activities found against the order.\"","data":"array. Audit rows of this uuid, newest first. [] = none.","data[].id":"int. SYNTHETIC: request time in ms + row index. Changes on every call — not an id, do not store it.","data[].identifier":"string. Unique id of the activity (one row per identifier; a re-sent identifier updates the row).","data[].uuid":"string. The order uuid (or a POS session uuid for non-order activities).","data[].title":"string. Action name, e.g. \"Order Created\", \"Item Added\", \"Dine Order\", \"Paid\", \"Void\", \"Change Payment Method\", \"Payment Method Updated\", \"Refund Items\", \"Transfer Order Items\", \"Discount Applied\". Shown in bold.","data[].message":"string. Detail text, e.g. \"Cancelled order reason: X\", \"paid [30] using [CASH]\". Shown after the title.","data[].username":"string. User who did it. Shown.","data[].user_id":"int. That user's id (list_pos_users).","data[].role":"string. That user's role.","data[].timestamp":"int | string. Unix SECONDS when written by the POS; ISO date-time string when written by the server (payment-method change). Handle both.","data[].platform":"string. \"web\" (web-pos), \"android\", \"ios\"… Shown after the username.","data[].device_model":"string. Device/browser, e.g. \"Chrome on Windows\".","data[].device_id":"string. Device id of the POS.","data[].app_version":"string. App version \"version (build)\".","data[].location_id":"int. Branch id.","data[].customer_id":"int. Tenant id.","data[].session_id":"string. POS login session id (\"\" when none).","data[].order_detail":"object | null. Snapshot of the order when the activity was logged (same shape as a list_orders_history order, as the POS held it). Can be large.","data[].customer_invoice_id":"int | null. Only on server-written rows: invoice number.","data[].order_no":"int | null. Only on server-written rows: order number."},"master_rules":["web-pos shows the trail in a collapsed \"Activity\" section of the order detail (InvoiceActivitySection.jsx). On expand it first uploads its local queue, then GETs this route and sorts the rows OLDEST first (timestamp, then id, then identifier). Each row shows title — message, username · platform, date + time. The section is hidden when ios_modules.is_activities_enabled = 0.","Who writes activities: the POS clients, not the order routes.Rows are written with log_invoice_activities (POST /api/v2/activities). The server itself writes a row only for update_invoice_payment_methods (\"Payment Method Updated\").","So actions an agent does with cancel_invoice or refund_invoice do NOT appear here. Only update_invoice_payment_methods leaves a row.","web-pos sends its activities to NEXT_PUBLIC_TRACK_API_URL (default https://app.syspos.ae/api/v2/), so a test server can show [] for real orders.","Rows without an identifier are merged into ONE row by the server (only the newest survives).","Links: uuid comes from list_orders_history / list_dine_orders; user_id → list_pos_users."],"example":{"status":true,"message":"No activities found against the order.","data":[]}},"parameters":{"type":"object","properties":{"uuid":{"type":"string","description":"Required."}},"required":["uuid"]},"module":"pos_session","safety":"read"},{"name":"get_maintenance_status","description":"GET /api/v2/maintenance/status. Why: Global (all tenants) maintenance switch. web-pos polls it every 2 minutes and blocks the whole POS with a maintenance screen while is_maintenance = 1 or the server answers 500/502/503. Check it before a batch of writes (sync_invoice etc.) or when calls start failing. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_maintenance_status\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/maintenance/status","why":"Global (all tenants) maintenance switch. web-pos polls it every 2 minutes and blocks the whole POS with a maintenance screen while is_maintenance = 1 or the server answers 500/502/503. Check it before a batch of writes (sync_invoice etc.) or when calls start failing.","response":{"description":"{ status: true, data: { is_maintenance, message } }, no-cache headers. Reads the first app_config row (none → 0). Error: 500 { status:false, message: \"DB error\", error }.","fields":{"status":"boolean. true.","data":"object. The flag.","data.is_maintenance":"0 | 1. 1 = platform in maintenance: do not write; retry later.","data.message":"string. \"All systems are up and running smoothly.\" or the scheduled-maintenance text (fixed by the server)."},"master_rules":["web-pos (MaintenanceContext.jsx): polls on start and every 120 s. is_maintenance === 1, or HTTP 500/502/503, → full-screen maintenance (app blocked). A network failure (offline) is NOT maintenance: the POS keeps selling offline.","Platform-wide, not per tenant or branch."],"caveats":"web-pos adds app_version / app_type / os_type query params; the server ignores them.","example":{"status":true,"data":{"is_maintenance":0,"message":"All systems are up and running smoothly."}}},"parameters":{"type":"object","properties":{},"required":[]},"module":"pos_session","safety":"read"},{"name":"update_user_permissions","description":"POST /api/v2/update-user-permissions. Why: Save POS switches of the SESSION user (the API key user) — what web-pos Settings does: one key per call (e.g. { discount_codes: 1 }, { tip_amount: 0 }, { channel_id: 463 } from Settings → Channel). PARTIAL merge: only the keys you send change. Turns features on/off for this user's till: is_channels_enable + channel_id (list_selling_channels), is_soldout (update_product_availability), discount_codes (discount codes + list_selling_coupons / validate_coupon), is_change_payment_method_enabled (update_invoice_payment_methods), tip_amount, dining_guest_count, combo_options. Read the current values first with list_pos_users (own row). Only role admin may call it (else 401). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_user_permissions\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/v2/update-user-permissions","why":"Save POS switches of the SESSION user (the API key user) — what web-pos Settings does: one key per call (e.g. { discount_codes: 1 }, { tip_amount: 0 }, { channel_id: 463 } from Settings → Channel). PARTIAL merge: only the keys you send change. Turns features on/off for this user's till: is_channels_enable + channel_id (list_selling_channels), is_soldout (update_product_availability), discount_codes (discount codes + list_selling_coupons / validate_coupon), is_change_payment_method_enabled (update_invoice_payment_methods), tip_amount, dining_guest_count, combo_options. Read the current values first with list_pos_users (own row). Only role admin may call it (else 401).","response":{"description":"{ status: true, message: \"setting saved successfully.\", data: { setting } }. Errors: 401 { data: { error: \"Unauthorized access!\" } } when the session user's role is not admin; 400 { status:false, message: \"Socket server admin already exists\", data: { has_socket_server_admin:false, admin:{ id, username, email } } } for is_socket_server 1 when another admin has it. A failed UPDATE is only logged — the answer is still status true with the OLD values.","fields":{"status":"boolean. true (also when the UPDATE itself failed — compare setting).","message":"string. \"setting saved successfully.\"","data":"object. Only { setting }.","data.setting":"object. The session user's settings AFTER the save: same keys as list_pos_users data[].setting except is_master_locations_enabled and currency (includes is_multi_location_allowed).","data.setting.print_receipt":"0 | 1. Auto print receipt. Printing only.","data.setting.kds_auto_receipt":"0 | 1. Auto print KDS ticket. Printing only.","data.setting.invoice_auto_receipt":"0 | 1. Auto print invoice. Printing only.","data.setting.cart_options":"0 | 1. Cart options menu. UI only.","data.setting.print_and_save":"0 | 1. Print after save. Printing only.","data.setting.cloud_print":"0 | 1. Cloud print. Printing only.","data.setting.quit_printers_with_cloud_enabled":"0 | 1. Printing only.","data.setting.invoice_printer_act_as_kds":"0 | 1. Printing only.","data.setting.cart_note":"0 | 1. Order note field. UI only.","data.setting.cart_car_plate":"0 | 1. Car-plate field. UI only.","data.setting.token_number":"0 | 1. Token number on receipts. Printing only.","data.setting.discount_codes":"0 | 1. USE: 1 = discount-code list (get_user_extras discount_code) AND coupon entry (list_selling_coupons / validate_coupon) are offered; 0 = both hidden. Missing → off.","data.setting.transfer_order_pincode":"0 | 1. PIN before transferring an order. UI only.","data.setting.daily_sale_input_check":"0 | 1. Daily sales input check. NOT USED by web-pos.","data.setting.daily_sale_media_check":"0 | 1. NOT USED by web-pos.","data.setting.active_pincode":"0 | 1. PIN lock. UI only.","data.setting.label_printing":"0 | 1. Printing only.","data.setting.qrcode_on_label_print":"0 | 1. Printing only.","data.setting.is_kds_device":"0 | 1. This till prints KDS tickets. Printing only.","data.setting.open_drawer":"0 | 1. Open cash drawer. Hardware only.","data.setting.user_card_digits":"0 | 1. Ask last card digits. UI only.","data.setting.invoice_qrcode":"0 | 1. QR on invoice. Printing only.","data.setting.force_adons":"0 | 1. Force add-on popup. UI only.","data.setting.combo_options":"0 | 1 | absent. USE: 0 = fixed-price combos (combo product's own price, is_combo 1); 1 = per-child pricing (Σ chosen children combo_price, is_combo 2). Absent when the stored permission list is short (treat as 0).","data.setting.ensure_combo_minimum_item_requirement":"0 | 1 | absent. USE: 1 = every combo bucket must be filled.","data.setting.row_discount":"0 | 1. Per-line discount allowed. NOT USED by web-pos.","data.setting.sms_invoice":"0 | 1. NOT USED by web-pos.","data.setting.offline_order_sync":"0 | 1. UI only.","data.setting.orderhistory_print_receipt":"0 | 1. Printing only.","data.setting.dining_guest_count":"0 | 1. USE: 1 = ask the number of guests on the first save of a dine-in order (sync_invoice dining_guests).","data.setting.is_brand_groups":"0 | 1. Brand groups UI.","data.setting.is_extras_enble":"0 | 1. Extras (materials) UI. Out of scope.","data.setting.display_modifier_titles":"0 | 1. UI only.","data.setting.modifier_sort_min_required_desc":"0 | 1. Modifier sort. UI only.","data.setting.print_only_save_kds_orders":"0 | 1. Printing only.","data.setting.print_note_on_receipt":"0 | 1. Printing only.","data.setting.show_receipt_screen":"0 | 1. UI only.","data.setting.open_dine_in":"0 | 1. UI only.","data.setting.hide_cart_pay_button":"0 | 1. UI only.","data.setting.tip_amount":"0 | 1. USE: 1 = tips on: an overpay on a non-cash tender may be taken as a tip (sync_invoice tip_amount); 0 = overpay is change.","data.setting.print_void_orders":"0 | 1. Printing only.","data.setting.has_void_order_permission":"0 | 1. Void permission (Android). NOT USED by web-pos.","data.setting.hide_clear_checkout":"0 | 1. UI only.","data.setting.kds_instruction":"0 | 1. Printing only.","data.setting.pay_default_usd":"0 | 1. NOT USED by web-pos.","data.setting.rpt_business_logo":"0 | 1. Receipt option. Printing only.","data.setting.rpt_order_type":"0 | 1. Printing only.","data.setting.rpt_is_modified":"0 | 1. Printing only.","data.setting.rpt_biller_info":"0 | 1. Printing only.","data.setting.rpt_total_before_vat":"0 | 1. Printing only.","data.setting.rpt_vat_info":"0 | 1. Printing only.","data.setting.rpt_net_total":"0 | 1. Printing only.","data.setting.rpt_exchange_rate":"0 | 1. Printing only.","data.setting.rpt_dine_guest_count":"0 | 1. Printing only.","data.setting.rpt_paid_at":"0 | 1. Printing only.","data.setting.esc_command":"0 | 1. Printing only.","data.setting.one_table_one_order":"0 | 1. One open order per table (Android). NOT USED by web-pos.","data.setting.qr_code_on_print_receipt":"0 | 1. Printing only.","data.setting.is_channels_enable":"0 | 1. USE: 1 = channels on: web-pos selects channel_id (else the first of list_selling_channels) and discount codes honour channel_ids; 0 = no channel (\"Main\").","data.setting.delivery_detail_enable":"0 | 1. Delivery details form. UI only.","data.setting.is_attributed_receipt":"0 | 1. Printing only.","data.setting.is_cds_enable":"0 | 1. Customer display. NOT USED by agents.","data.setting.external_order_print_receipt":"0 | 1. Printing only.","data.setting.text_receipt":"0 | 1. Printing only.","data.setting.each_category_kds_print":"0 | 1. Printing only.","data.setting.unify_brands":"0 | 1. Show all brands' products together. UI only.","data.setting.ingredient_stay_standalone":"0 | 1. NOT USED by web-pos.","data.setting.allegy_item":"0 | 1. Allergy item flag (server spelling). NOT USED by web-pos.","data.setting.dinein_serving_role":"0 | 1. NOT USED by web-pos.","data.setting.draft_order":"0 | 1. NOT USED by web-pos.","data.setting.is_kds_expeditor":"0 | 1. KDS expeditor. NOT USED by agents.","data.setting.is_qr_payment":"0 | 1. NOT USED by web-pos.","data.setting.pay_zero_amount":"0 | 1. USE: 1 = a zero-payable order completes with { cash: 0 } without the payment step.","data.setting.print_deleted_item_on_receipt":"0 | 1. Printing only.","data.setting.receipt_format":"0 | 1. Receipt template. Printing only.","data.setting.receipt_translation":"0 | 1. Printing only.","data.setting.product_translation":"0 | 1. UI only.","data.setting.category_translation":"0 | 1. UI only.","data.setting.print_invoice_translation":"0 | 1. Printing only.","data.setting.print_kds_translation":"0 | 1. Printing only.","data.setting.reservation_with_dine_in":"0 | 1. NOT USED by web-pos.","data.setting.splitefire":"0 | 1. KDS split fire (server spelling). NOT USED by agents.","data.setting.is_ingredient_disabled":"0 | 1. Hide ingredients. NOT USED by web-pos.","data.setting.is_socket_server":"0 | 1. This device is the peer socket server. NOT USED by agents.","data.setting.is_client":"0 | 1. Peer socket client. NOT USED by agents.","data.setting.open_price_pincode":"0 | 1. PIN before open price. UI only.","data.setting.is_transfer_enabled":"0 | 1. Transfer-order button. UI only.","data.setting.block_print_until_order_open":"0 | 1. Printing only.","data.setting.web_local_network_printer":"0 | 1. Printing only.","data.setting.user_product_sales_counter":"0 | 1. Product sold counter screen. UI only.","data.setting.is_change_payment_method_enabled":"0 | 1. USE: 1 = 'Change payment method' is offered on paid orders (update_invoice_payment_methods); 0 = hidden.","data.setting.skip_zero_quantity_items":"0 | 1. Printing only.","data.setting.skip_zero_amount_items":"0 | 1. Printing only.","data.setting.settle_wallet_topup":"0 | 1. NOT USED by web-pos.","data.setting.is_shift_management":"0 | 1. Shift fallback switch (shift tools out of scope).","data.setting.acknowledge_dine_table_edit":"0 | 1. UI only.","data.setting.is_soldout":"0 | 1. USE: 1 = the user may mark products sold out / available (update_product_availability); 0 = no.","data.setting.force_crm_user":"0 | 1. Customer required on orders (Android). NOT USED by web-pos.","data.setting.shift_transactions":"0 | 1. Shift history (out of scope).","data.setting.eod_shift_report":"0 | 1. End-of-day card (out of scope).","data.setting.shift_z_report":"0 | 1. Z report (out of scope).","data.setting.view_estimated_cash":"0 | 1. Estimated cash (out of scope).","data.setting.external_order_ready":"0 | 1. Aggregator 'ready' push. NOT USED by agents.","data.setting.channel_id":"int. USE: default channel (list_selling_channels channel_id; 0 = first). Saved by web-pos Settings → Channel.","data.setting.brand_id":"int. User's default brand (0 = none). USE: web-pos pre-selects this brand.","data.setting.print_no_of_times":"int. Receipt copies. Printing only.","data.setting.language":"string. App language code (\"\" / \"en\" / \"ar\"). NOT USED by web-pos.","data.setting.zebra_ip":"string. Label printer IP. Printing only.","data.setting.zebra_port":"int. Label printer port. Printing only.","data.setting.fraction_digits":"int. Qty fraction digits (Android). NOT USED for money (use get_user_extras decimal_points).","data.setting.order_type":"int. Default order type toggle. UI only.","data.setting.exchange_format":"int. NOT USED by web-pos.","data.setting.station_id":"int. KDS station. NOT USED by agents.","data.setting.lock_screen":"int. Lock-screen timeout. UI only.","data.setting.cloud_target_user_id":"int | null. Cloud-print target user. Printing only.","data.setting.cloud_target_device_id":"string | null. Cloud-print target device. Printing only.","data.setting.is_multi_location_allowed":"0 | 1. User may switch branches (web-pos shows the branch picker after login). Dashboard-managed.","data.setting.exchange_rate":"number. The exchange_rate you sent; otherwise the rpt_exchange_rate flag (0 | 1), NOT the rate (server bug)."},"master_rules":["web-pos Settings (app/settings/page.jsx): each toggle sends ONE key { [key]: 1|0 } via updateSettings; the answer's data.setting is merged into redux appSettings, localStorage user.setting and the IndexedDB users row. The toggle also logs a \"Permission Changed\" activity \"<label> · Enabled/Disabled\" (log_invoice_activities).","Settings → Channel (ChannelModal.jsx) saves { channel_id } as the user's default channel.","Keys web-pos Settings can toggle: print_receipt, kds_auto_receipt, invoice_auto_receipt, print_and_save, cart_options, cart_note, cart_car_plate, token_number, discount_codes, transfer_order_pincode, active_pincode, label_printing, qrcode_on_label_print, is_kds_device, open_drawer, invoice_qrcode, force_adons, cloud_print, tip_amount, dining_guest_count, is_transfer_enabled, is_brand_groups, unify_brands, print_void_orders, qr_code_on_print_receipt, text_receipt, each_category_kds_print, print_only_save_kds_orders, show_receipt_screen, hide_cart_pay_button, delivery_detail_enable, display_modifier_titles, offline_order_sync, orderhistory_print_receipt, order_type (+ channel_id). is_soldout, is_channels_enable, is_change_payment_method_enabled and combo_options are normally set from the dashboard / Android but this route accepts them too.","Merge rule: a key that is absent or null keeps its stored value; 0 is saved. Send integers 0 / 1 — a boolean true is stored as \"true\" in the positional permissions string and reads back as null.","Always the caller's own row (JWT user id); there is no user_id parameter. It does NOT change another user's switches.","Effect is on this user's POS apps after their next login / Settings refresh; orders created through MCP are not gated by these switches, but follow them for web-pos parity."],"caveats":"Only the session user; admin role required. exchange_rate changes the BRANCH config for every user. data.setting.exchange_rate echoes the rpt_exchange_rate flag when no rate is sent. is_multi_location_allowed is accepted by the server but deliberately not exposed (dashboard-managed access). The server also reads ?override=1; not exposed.","example":{"request":{"discount_codes":1},"response":{"status":true,"message":"setting saved successfully.","data":{"setting":{"print_receipt":1,"discount_codes":1,"tip_amount":0,"dining_guest_count":1,"is_channels_enable":0,"channel_id":463,"is_soldout":0,"is_change_payment_method_enabled":0,"combo_options":1,"exchange_rate":0}}},"note":"Code-derived (write route not called live); data.setting is abbreviated."}},"parameters":{"type":"object","properties":{"print_receipt":{"type":"number","description":"Optional."},"kds_auto_receipt":{"type":"number","description":"Optional."},"invoice_auto_receipt":{"type":"number","description":"Optional."},"cart_options":{"type":"number","description":"Optional."},"print_and_save":{"type":"number","description":"Optional."},"cloud_print":{"type":"number","description":"Optional."},"quit_printers_with_cloud_enabled":{"type":"number","description":"Optional."},"invoice_printer_act_as_kds":{"type":"number","description":"Optional."},"cart_note":{"type":"number","description":"Optional."},"cart_car_plate":{"type":"number","description":"Optional."},"token_number":{"type":"number","description":"Optional."},"discount_codes":{"type":"number","description":"Optional."},"transfer_order_pincode":{"type":"number","description":"Optional."},"daily_sale_input_check":{"type":"number","description":"Optional."},"daily_sale_media_check":{"type":"number","description":"Optional."},"active_pincode":{"type":"number","description":"Optional."},"label_printing":{"type":"number","description":"Optional."},"qrcode_on_label_print":{"type":"number","description":"Optional."},"is_kds_device":{"type":"number","description":"Optional."},"open_drawer":{"type":"number","description":"Optional."},"user_card_digits":{"type":"number","description":"Optional."},"invoice_qrcode":{"type":"number","description":"Optional."},"force_adons":{"type":"number","description":"Optional."},"combo_options":{"type":"number","description":"Optional."},"ensure_combo_minimum_item_requirement":{"type":"number","description":"Optional."},"sms_invoice":{"type":"number","description":"Optional."},"offline_order_sync":{"type":"number","description":"Optional."},"orderhistory_print_receipt":{"type":"number","description":"Optional."},"dining_guest_count":{"type":"number","description":"Optional."},"is_brand_groups":{"type":"number","description":"Optional."},"is_extras_enble":{"type":"number","description":"Optional."},"display_modifier_titles":{"type":"number","description":"Optional."},"modifier_sort_min_required_desc":{"type":"number","description":"Optional."},"print_only_save_kds_orders":{"type":"number","description":"Optional."},"print_note_on_receipt":{"type":"number","description":"Optional."},"show_receipt_screen":{"type":"number","description":"Optional."},"open_dine_in":{"type":"number","description":"Optional."},"hide_cart_pay_button":{"type":"number","description":"Optional."},"tip_amount":{"type":"number","description":"Optional."},"print_void_orders":{"type":"number","description":"Optional."},"has_void_order_permission":{"type":"number","description":"Optional."},"hide_clear_checkout":{"type":"number","description":"Optional."},"kds_instruction":{"type":"number","description":"Optional."},"pay_default_usd":{"type":"number","description":"Optional."},"rpt_business_logo":{"type":"number","description":"Optional."},"rpt_order_type":{"type":"number","description":"Optional."},"rpt_is_modified":{"type":"number","description":"Optional."},"rpt_biller_info":{"type":"number","description":"Optional."},"rpt_total_before_vat":{"type":"number","description":"Optional."},"rpt_vat_info":{"type":"number","description":"Optional."},"rpt_net_total":{"type":"number","description":"Optional."},"rpt_exchange_rate":{"type":"number","description":"Optional."},"rpt_dine_guest_count":{"type":"number","description":"Optional."},"rpt_paid_at":{"type":"number","description":"Optional."},"esc_command":{"type":"number","description":"Optional."},"one_table_one_order":{"type":"number","description":"Optional."},"qr_code_on_print_receipt":{"type":"number","description":"Optional."},"is_channels_enable":{"type":"number","description":"Optional."},"delivery_detail_enable":{"type":"number","description":"Optional."},"is_attributed_receipt":{"type":"number","description":"Optional."},"is_cds_enable":{"type":"number","description":"Optional."},"external_order_print_receipt":{"type":"number","description":"Optional."},"text_receipt":{"type":"number","description":"Optional."},"each_category_kds_print":{"type":"number","description":"Optional."},"unify_brands":{"type":"number","description":"Optional."},"ingredient_stay_standalone":{"type":"number","description":"Optional."},"allegy_item":{"type":"number","description":"Optional."},"dinein_serving_role":{"type":"number","description":"Optional."},"draft_order":{"type":"number","description":"Optional."},"is_kds_expeditor":{"type":"number","description":"Optional."},"is_qr_payment":{"type":"number","description":"Optional."},"pay_zero_amount":{"type":"number","description":"Optional."},"print_deleted_item_on_receipt":{"type":"number","description":"Optional."},"receipt_format":{"type":"number","description":"Optional."},"receipt_translation":{"type":"number","description":"Optional."},"product_translation":{"type":"number","description":"Optional."},"category_translation":{"type":"number","description":"Optional."},"print_invoice_translation":{"type":"number","description":"Optional."},"print_kds_translation":{"type":"number","description":"Optional."},"reservation_with_dine_in":{"type":"number","description":"Optional."},"splitefire":{"type":"number","description":"Optional."},"is_ingredient_disabled":{"type":"number","description":"Optional."},"is_socket_server":{"type":"number","description":"Optional."},"is_client":{"type":"number","description":"Optional."},"open_price_pincode":{"type":"number","description":"Optional."},"is_transfer_enabled":{"type":"number","description":"Optional."},"block_print_until_order_open":{"type":"number","description":"Optional."},"web_local_network_printer":{"type":"number","description":"Optional."},"user_product_sales_counter":{"type":"number","description":"Optional."},"is_change_payment_method_enabled":{"type":"number","description":"Optional."},"skip_zero_quantity_items":{"type":"number","description":"Optional."},"skip_zero_amount_items":{"type":"number","description":"Optional."},"settle_wallet_topup":{"type":"number","description":"Optional."},"is_shift_management":{"type":"number","description":"Optional."},"acknowledge_dine_table_edit":{"type":"number","description":"Optional."},"is_soldout":{"type":"number","description":"Optional."},"force_crm_user":{"type":"number","description":"Optional."},"shift_transactions":{"type":"number","description":"Optional."},"eod_shift_report":{"type":"number","description":"Optional."},"shift_z_report":{"type":"number","description":"Optional."},"view_estimated_cash":{"type":"number","description":"Optional."},"external_order_ready":{"type":"number","description":"Optional."},"row_discount":{"type":"number","description":"Optional."},"channel_id":{"type":"number","description":"Optional."},"brand_id":{"type":"number","description":"Optional."},"print_no_of_times":{"type":"number","description":"Optional."},"language":{"type":"string","description":"Optional."},"zebra_ip":{"type":"string","description":"Optional."},"zebra_port":{"type":"number","description":"Optional."},"fraction_digits":{"type":"number","description":"Optional."},"order_type":{"type":"number","description":"Optional."},"exchange_format":{"type":"number","description":"Optional."},"station_id":{"type":"number","description":"Optional."},"lock_screen":{"type":"number","description":"Optional."},"cloud_target_user_id":{"type":"number","description":"Optional."},"cloud_target_device_id":{"type":"string","description":"Optional."},"exchange_rate":{"type":"number","description":"Optional."}},"required":[]},"module":"pos_session","safety":"write"},{"name":"get_loyalty_card","description":"GET /api/v2/loyalty-program/card/:crmUserId. Why: A customer's loyalty card — what web-pos loads for the cart balance banner and before any redemption at payment. The tenant has ONE loyalty program (type point, stamp or membership) shared by every branch; a customer joins it only by enrolling (the card is NOT created by the POS, by create_pos_crm_user or by a sale). point: current_points and their money value (available_points_amount). stamp: current_stamps / stamps_required and the reward. membership: expiry and active flag. Read-only. Use it only when get_user_extras data.ios_modules.is_loyalty_enable = 1 and a customer is on the order (crm_user_id > 0 from list_pos_crm_users / create_pos_crm_user). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_loyalty_card\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/loyalty-program/card/:crmUserId","why":"A customer's loyalty card — what web-pos loads for the cart balance banner and before any redemption at payment. The tenant has ONE loyalty program (type point, stamp or membership) shared by every branch; a customer joins it only by enrolling (the card is NOT created by the POS, by create_pos_crm_user or by a sale). point: current_points and their money value (available_points_amount). stamp: current_stamps / stamps_required and the reward. membership: expiry and active flag. Read-only. Use it only when get_user_extras data.ios_modules.is_loyalty_enable = 1 and a customer is on the order (crm_user_id > 0 from list_pos_crm_users / create_pos_crm_user).","response":{"description":"Found: HTTP 200 { status:true, message:\"Loyalty card retrieved successfully.\", data: card }. Type-specific keys are present ONLY for that loyalty_type (point / stamp / membership). No card (not enrolled, or unknown id): HTTP 404 { status:false, message:\"No loyalty card found for this user.\", data:null } — the tool returns it as an error with status 404; treat it as \"no loyalty for this customer\", not a failure. Server error: HTTP 500 { status:false, message:\"An error occurred while retrieving loyalty card. <reason>\", data:null }.","fields":{"status":"boolean. true = card found.","message":"string. \"Loyalty card retrieved successfully.\" or the error text.","data":"object. The card (the newest one when the customer has several).","data.card_id":"int. Card id. USE: loyalty_card_id for validate_loyalty_burn.","data.crm_user_id":"int. Echo of the customer id.","data.loyalty_program_id":"int. Program the card belongs to.","data.loyalty_type":"string. \"point\" | \"stamp\" | \"membership\" (\"points\"/\"stamps\" = same). USE: decides the redemption flow. web-pos redeems only point and stamp cards.","data.customer_name":"string. first_name + last_name (mobile number when both empty). Display only.","data.email":"string | null. Customer email. Display only.","data.phone_number":"string | null. Customer mobile number. NOT put on the order by web-pos.","data.is_active":"int. ALWAYS 1 in this response, even for a disabled (already redeemed) stamp card — the server replaces 0 by 1. Do not rely on it; validate_loyalty_burn rejects a disabled card.","data.total_redeemed":"number. point: points burned so far in total. stamp: rewards redeemed so far. Display only.","data.last_redeemed_date":"string (ISO datetime) | null. Last redemption. Display only.","data.current_points":"number. point only. Points balance. Same value as available_points.","data.available_points":"number. point only. Points the customer can burn now. USE: the upper limit of burn_points; web-pos shows \"<available_points> points\" in the cart.","data.available_points_amount":"number. point only. Money value of available_points = available_points × redeem_amount / redeem_points (2 dp). USE: rate = available_points / available_points_amount points per currency unit; max points for this order = min(available_points, payable × rate). 0 when the balance is 0 (then the rate is unknown — use validate_loyalty_burn conversion_rate).","data.current_stamps":"int. stamp only. Stamps collected.","data.stamps_required":"int. stamp only. Stamps needed for the reward (default 5). USE: the reward can be redeemed when current_stamps >= stamps_required.","data.stamps_remaining":"int. stamp only. max(0, stamps_required − current_stamps). Display only.","data.stamps_visual":"string. stamp only. \"● ● ○ ○ ○\" style progress. Display only.","data.progress_percentage":"int. stamp only. 0–100. Display only.","data.last_stamp_date":"string (ISO datetime) | null. stamp only. Last stamp earned. Display only.","data.reward_type":"string | null. stamp only. \"discount\" or \"free_product\" — what the full card gives.","data.discount_type":"string | null. stamp only. \"percentage\" or \"value\" (fixed amount) for a discount reward.","data.discount_value":"number | null. stamp only. Percent or fixed amount of the discount reward.","data.discount_min_order":"number | null. stamp only. Minimum order_total for the discount reward (checked by validate_loyalty_burn).","data.product_id":"string | null. stamp only. Reward product id for a free_product reward (list_selling_products id, stored as text). USE: the product to send in validate_loyalty_burn product_ids.","data.product_name":"string | null. stamp only. Reward product name (free_product). Display only.","data.min_order":"number | null. stamp only. Minimum order_total for the free_product reward.","data.membership_price":"number | null. membership only. Price of the membership. NOT USED at checkout.","data.membership_period":"int | null. membership only. Length of the membership. NOT USED at checkout.","data.membership_period_unit":"string | null. membership only. Unit of membership_period. NOT USED at checkout.","data.membership_start_date":"string (ISO datetime) | null. membership only. Start of the membership.","data.membership_expiry_date":"string (ISO datetime) | null. membership only. End of the membership (null = no end).","data.is_membership_active":"boolean. membership only. true = not expired. Display only (web-pos has no membership flow).","data.program":"object. The loyalty program of the card.","data.program.id":"int. Program id.","data.program.name":"string. Program name. Display only.","data.program.description":"string | null. NOT USED.","data.program.reward_description":"string | null. Reward text. Display only.","data.program.is_active":"0 | 1. 0 = program switched off: the card still shows, but validate_loyalty_burn fails and no points are earned.","data.pass_info":"object. Apple/Google wallet pass of the card. NOT USED at checkout.","data.pass_info.serial_number":"string | null. NOT USED — wallet pass serial.","data.pass_info.type_identifier":"string | null. NOT USED — wallet pass type.","data.card_created_at":"string (ISO datetime). Enrollment time. NOT USED.","data.card_updated_at":"string (ISO datetime). Last balance change. NOT USED."},"master_rules":["Show / use loyalty only when get_user_extras data.ios_modules.is_loyalty_enable = 1 (1, true or \"1\") AND a customer is attached (crm_user_id > 0). Otherwise skip this tool.","web-pos fetches the card for the cart banner (point: \"<available_points> points\", stamp: \"<current_stamps> / <stamps_required> stamps\") and again at payment; a 404 means the customer is not enrolled — show nothing and continue the sale without loyalty.","point card: offer \"Redeem points\" when the payable before loyalty is > 0 and available_points > 0; then call validate_loyalty_burn. stamp card: offer \"Redeem stamps\" only when current_stamps >= stamps_required. membership card: no POS flow.","The balance changes only when a PAID order with this crm_user_id is saved (sync_invoice): the server burns the order's burn_points and adds the earned points itself. Re-read the card after checkout if you need the new balance.","Points are NOT given back when an order is cancelled or refunded (cancel_invoice, refund_invoice), and NOT reversed when a paid order is edited.","Past orders show loyalty per order in list_orders_history (earn_points, burn_points, burn_points_amount, available_points, available_points_amount)."],"caveats":"Live on dev1 (tenant 437, point program: redeem 10 points = 1.00). The card is looked up by crm_user_id only — always take the id from list_pos_crm_users. The program is tenant-wide: there is no branch check anywhere in loyalty. Points never expire; there are no tiers, no minimum points to redeem and no maximum redeem % on the server.","example":{"status":true,"message":"Loyalty card retrieved successfully.","data":{"card_id":57,"crm_user_id":63821,"loyalty_program_id":16,"loyalty_type":"point","customer_name":"Sara Ahmed","email":"sara.ahmed@example.com","phone_number":"+971500000001","is_active":1,"total_redeemed":0,"last_redeemed_date":null,"current_points":3,"available_points":3,"available_points_amount":0.3,"program":{"id":16,"name":"Loyalty 1","description":null,"reward_description":null,"is_active":1},"pass_info":{"serial_number":"00000000-0000-4000-8000-000000000057","type_identifier":"pass.com.syspos.pos"},"card_created_at":"2026-06-29T10:27:28.000Z","card_updated_at":"2026-08-20T11:07:43.000Z"}}},"parameters":{"type":"object","properties":{"crmUserId":{"type":"string","description":"Required."}},"required":["crmUserId"]},"module":"loyalty","safety":"read"},{"name":"validate_loyalty_burn","description":"POST /api/v2/loyalty-program/burn-validation. Why: Server check of a loyalty redemption before you apply it at payment — what web-pos calls from the Redeem points dialog (point program) and the Redeem stamps button (stamp program). Uses the tenant's program and the customer's card (from get_loyalty_card). point: checks burn_points <= current_points and returns burn_amount = burn_points × redeem_amount / redeem_points. stamp / membership: checks the card is full / not expired, the minimum order and returns discount_amount. Does NOT reserve or deduct anything: it only reads (no OTP is sent, nothing is stored); the points are burned when the PAID order is saved (sync_invoice). No side effects. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"validate_loyalty_burn\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/v2/loyalty-program/burn-validation","why":"Server check of a loyalty redemption before you apply it at payment — what web-pos calls from the Redeem points dialog (point program) and the Redeem stamps button (stamp program). Uses the tenant's program and the customer's card (from get_loyalty_card). point: checks burn_points <= current_points and returns burn_amount = burn_points × redeem_amount / redeem_points. stamp / membership: checks the card is full / not expired, the minimum order and returns discount_amount. Does NOT reserve or deduct anything: it only reads (no OTP is sent, nothing is stored); the points are burned when the PAID order is saved (sync_invoice). No side effects.","response":{"description":"Success: HTTP 200 { status:true, message, data:{ status:\"Success\", … } } — point: message \"Burn validation successful\"; stamp: \"Stamp-based discount reward validation\" / \"Stamp-based free product reward validation\"; membership: \"Membership card validation successful\". Rejected: HTTP 400 or 404 { status:false, message, data?:{ status:\"Failed\", message?, … } } — the tool returns it as an error with status and data = that body; show data.message (the top-level message). Messages: \"CRM User ID is required\" | \"Customer ID is required\" | \"Order total is required and must be greater than 0\" | 404 \"No active loyalty program found\" (data: available_points 0, burn_points 0, burn_amount 0) | 404 \"You are not currently enrolled in the loyalty program.\" (no card) | 404 \"Loyalty card not found\" | \"This loyalty card has already been redeemed and is no longer active.\" (disabled stamp card; data.card_id, data.is_active) | \"Burn points must be a positive number\" | \"Insufficient points\" (data.message \"Insufficient points. Available: <n>, Requested: <m>\", data.available_points, data.points_short) | \"You need <n> more stamp(s) to redeem this reward.\" (data.current_stamps, stamps_required, stamps_short, is_ready_to_redeem) | \"Add <x> more to your order to get this discount.\" / \"… to claim this free product.\" / \"… to apply this membership benefit.\" / \"… to claim this membership benefit.\" (data.min_order_value, current_order_total, short_by) | \"Discount percentage cannot be more than 100%\" | \"This discount cannot be applied to your current order. Your order total is <t>.\" | \"Product IDs are required for free product reward\" | \"The selected products are not eligible for this reward. Please select the correct products.\" (data.reward_product_id, provided_product_ids) | \"Products not found for the selected location\" | \"Membership has expired.\" | \"Unsupported … type …\" | \"Burn validation is not supported for loyalty type: <type>\". Server error: HTTP 500 { status:false, message:\"Internal server error during burn validation\", error, payload } or { status:false, message:\"Error validating products\", error }.","fields":{"status":"boolean. true = the redemption may be applied. false = do not apply; show message.","message":"string. Success text or the rejection reason (see description).","data":"object. Result details (on success and most rejections).","data.status":"string. \"Success\" or \"Failed\". web-pos applies the redemption only when it is \"Success\".","data.available_points":"number. point only. Card balance BEFORE this burn (on \"Insufficient points\" too). USE: available_points on the order.","data.burn_points":"number. point only. Points accepted (= burn_points sent; 0 on rejections). USE: burn_points on the order.","data.burn_amount":"number. point only. Money value of the burn = burn_points × conversion_rate (2 dp), NOT capped by order_total. USE: burn_points_amount on the order and the amount taken off the payable.","data.points_after_burn":"number. point only. available_points − burn_points (before any earning on this order). Display only.","data.conversion_rate":"number. point only. Money per point = redeem_amount / redeem_points (0 = redemption worth nothing). USE to cap burn_points: max points = payable / conversion_rate.","data.points_short":"number. point, \"Insufficient points\" only. Points missing.","data.loyalty_program_id":"int. point / membership. Program id. NOT sent on the order.","data.loyalty_card_id":"int. point / membership. Card id. web-pos sends it as loyalty_card_id on the order (the server ignores it).","data.crm_user_id":"number | string. point / membership. Echo of the request.","data.order_total":"number. Echo of order_total (point, percentage rewards, membership value).","data.discount_reason":"string. \"Loyalty discount\" (\"Loyalty membership discount\" for membership). USE: discount_reason on the order — web-pos always sends \"Loyalty discount\".","data.loyalty_type":"string. stamp / membership only. \"stamp\" or \"membership\".","data.reward_type":"string. stamp / membership only. \"discount\" or \"free_product\".","data.discount_type":"string. discount rewards only. \"percentage\" or \"value\".","data.discount_value":"number. discount rewards only. Percent or fixed amount.","data.discount_amount":"number. stamp / membership only. Money off the order: order_total × percent / 100 (2 dp), the fixed value, or the summed price of the products sent (free_product). USE: burn_points_amount on the order and the amount taken off the payable.","data.final_amount":"number. fixed-value discount only. order_total − discount_value. Display only.","data.min_order_value":"number. discount rewards: the minimum order that was checked (0 = none).","data.is_ready_to_redeem":"boolean. stamp only. Always true on success.","data.products":"array. free_product rewards only. [{ product_id, product_name, product_price }] of the products priced.","data.products[].product_id":"int. Product id.","data.products[].product_name":"string. Product name. Display only.","data.products[].product_price":"number. Branch price (products_locations actual_price) counted into discount_amount.","data.is_membership_active":"boolean. membership only. true on success.","data.current_stamps":"int. stamp rejection (\"need more stamps\") only. Stamps on the card.","data.stamps_required":"int. stamp rejection only. Stamps needed.","data.stamps_short":"int. stamp rejection only. Stamps missing.","data.current_order_total":"number. minimum-order rejection only. order_total sent.","data.short_by":"number. minimum-order rejection only. Amount missing to reach the minimum.","data.reward_product_id":"string. free_product \"not eligible\" rejection only. The reward product id.","data.provided_product_ids":"array. free_product \"not eligible\" rejection only. Echo of product_ids.","data.card_id":"int. \"already redeemed\" rejection only. Disabled card id.","data.is_active":"0. \"already redeemed\" rejection only."},"master_rules":["Only when get_user_extras data.ios_modules.is_loyalty_enable = 1, crm_user_id > 0 and the payable before loyalty is > 0. Call get_loyalty_card first: point → burn_points flow; stamp → only when current_stamps >= stamps_required; membership → web-pos has no flow.","Order of application (web-pos): cart lines → order discount % (manual discount, discount code or basket coupon — validate_coupon / list_selling_coupons first) → loyalty. order_total = the payable at that point. The loyalty amount is then added to the order discount amount and payable = max(0, that payable − burn amount). VAT is taken from the reduced payable.","point: validate burn_points <= available_points and <= min(available_points, order_total × available_points / available_points_amount) (web-pos cap). web-pos validates, shows burn_amount, and applies it only when the cashier confirms. Only ONE redemption per order: to change it, drop it and validate again.","Apply = on the PAID order (sync_invoice): burn_points = data.burn_points (0 for stamp), burn_points_amount = data.burn_amount (point) or data.discount_amount (stamp), available_points = data.available_points (point) or the card's current_stamps (stamp), available_points_amount = get_loyalty_card available_points_amount, discount_reason = \"Loyalty discount\", earn_points = 0 (the server computes it), crm_user_id = the customer, is_loyalty_enable = ios_modules.is_loyalty_enable (0/1); loyalty_mobile_number and loyalty_mobile_code = \"\". Orders saved unpaid (held/table) carry all of these as 0 / \"\".","Drop the redemption (validate again) whenever the cart total or the order discount % changes, and when the customer is attached, changed or removed (list_pos_crm_users) — web-pos clears it in all these cases. It also resets any tenders and tip taken so far, so redeem BEFORE taking payment.","Wallet: loyalty is a discount, not a tender. Tenders (list_selling_payment_methods, incl. Wallet) pay the payable AFTER the burn; if the burn makes it 0, record the order as { \"cash\": 0 }.","The server re-computes on save: burn_points is capped at the card balance, burn_points_amount = burn_points × redeem_amount / redeem_points (so a stamp reward is stored with burn_points_amount 0), and earn_points is computed from the order payable (after the burn): earn_on_every_spend = 1 → payable / spend_amount × points_earned; otherwise points_earned once when payable >= spend_amount; no spend rule set → 1 point. A stamp program gives 1 stamp per paid order, or — when discount_reason is \"Loyalty discount\" — resets the stamps to 0 and disables the card. Earning happens only once, when the order first becomes paid, and only for enrolled customers.","Nothing is reserved: two orders can validate the same points; the second save simply burns what is left. Points are not returned by cancel_invoice or refund_invoice."],"caveats":"Code-derived (validate is a POST; not called live). Proven read-only: the handler only runs SELECTs (loyalty_programs, user_loyalty_cards, products/products_locations); the OTP code is commented out. It uses the tenant's OLDEST loyalty program; if that one is switched off every call returns \"No active loyalty program found\". A redeemed stamp card stays disabled (get_loyalty_card still shows is_active 1). The free_product price comes from the session branch.","example":{"status":true,"message":"Burn validation successful","data":{"status":"Success","available_points":30,"burn_points":20,"burn_amount":2,"points_after_burn":10,"conversion_rate":0.1,"loyalty_program_id":16,"loyalty_card_id":57,"crm_user_id":63821,"order_total":45.5,"discount_reason":"Loyalty discount"}}},"parameters":{"type":"object","properties":{"crm_user_id":{"type":"string","description":"Required."},"order_total":{"type":"number","description":"Required, > 0."},"burn_points":{"type":"number","description":"Required for a point program: points to burn, > 0 and <= get_loyalty_card data.available_points (missing / 0 → 400 \"Burn points must be a positive number\")."},"loyalty_card_id":{"type":"string","description":"Optional."},"product_ids":{"type":"array","items":{"type":"number","description":"Product id (list_selling_products id)."},"description":"Required only for a free_product reward (stamp or membership): must contain the reward product (get_loyalty_card data.product_id)."}},"required":["crm_user_id","order_total"]},"module":"loyalty","safety":"read"},{"name":"list_locations","description":"GET /api/locations. Why: Every branch (location) of the tenant this key may use, with its id and name — the id is the loc / location_id that every other tool takes (get_pos_configs, get_location_config, get_business_info, get_business_hours, get_location_days_hours, list_* catalog tools, create_* location_id lists). Call it first whenever the user names a branch, to turn the name into an id. Also shows the warehouse flag and the per-branch payment settings. This is the unpaginated list every dashboard picker uses; the dashboard Locations page uses the paginated /api/locations/v2 (same rows, fewer columns, newest first) — not exposed because this tool returns the same data in one call and MCP filters it to the key's branches. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_locations\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/locations","why":"Every branch (location) of the tenant this key may use, with its id and name — the id is the loc / location_id that every other tool takes (get_pos_configs, get_location_config, get_business_info, get_business_hours, get_location_days_hours, list_* catalog tools, create_* location_id lists). Call it first whenever the user names a branch, to turn the name into an id. Also shows the warehouse flag and the per-branch payment settings. This is the unpaginated list every dashboard picker uses; the dashboard Locations page uses the paginated /api/locations/v2 (same rows, fewer columns, newest first) — not exposed because this tool returns the same data in one call and MCP filters it to the key's branches.","response":{"description":"Bare JSON array (no envelope), unordered (id order in practice). Server scope: the tenant's first (main) user sees every branch of the tenant; any other user sees only the ids in its users.allow_locations (else its own location_id). MCP then drops rows whose id is not in the API key's allow_locations. Error: HTTP 500 { error: \"Failed to fetch locations\", detail }.","fields":{"[]":"array. One object per branch. [] = no visible branch.","[].id":"int. Location (branch) id. This is the loc / location_id every other tool takes.","[].name":"string. Branch name (unique per tenant, case as stored).","[].is_ware_house":"string. \"1\" = branch is also a warehouse (offered as a stock source in stock order requests), \"0\" = not; NULL when never set. Stored as a string.","[].enable_menu_checkout":"int or null. 1 = QR/online menu checkout enabled for this branch, 0 = disabled, null = never set (treat as 0). Not edited by the dashboard location form.","[].payment_provider":"string or null. Online payment gateway id for pay-at-table / QR payments (dashboard Payment Providers page values e.g. \"telr\", \"montypay\"); null = none.","[].ngenius_terminal_id":"string or null. Network International N-Genius card terminal id (TID); null = no device.","[].ngenius_merchant_id":"string or null. N-Genius merchant id (MID); null = no device.","[].invoice_service_version":"string. \"v1\" (default) or \"v2\" — which invoice-service pipeline the branch uses."},"master_rules":["Location ids are tenant-global keys: loc (csv or single) on reads, location_id / location_ids on writes. On MASTER tenants catalog rows (products, categories, brands, groups, menus, modifiers, deals, labels, channels, payment methods) have location_id NULL and reach a branch through <entity>_locations link rows; the branch id you pick here is what those links carry.","MCP clamp: when the API key has allow_locations, this list is filtered to those ids (filterLocationsPayload), every loc / location_id query param is clamped to them (omitted = all allowed for loc, the FIRST allowed id for location_id), and a requested id outside them is an error. So a branch missing here cannot be used by any tool.","The web-pos selling tools (list_selling_*, sync_invoice, list_kds_orders, get_today_sales) run on the SESSION branch (the key user's login branch), not on a loc param.","Address, phone, email, TRN, logo, currency, VAT %, time zone and opening/closing hours are NOT columns of a location — they are per-branch config rows: read them with get_business_info / get_pos_configs / get_location_config (loc = this id)."],"caveats":"Query params are ignored by the server (no search, no paging). is_ware_house is a string (\"1\"/\"0\"), enable_menu_checkout an int or null. The dashboard's /api/locations/v2 returns { data, pagination } with only id, name, is_ware_house, enable_menu_checkout, invoice_service_version, ORDER BY id DESC, _page/_limit (default 10) — it is NOT filtered by MCP, which is why only this tool is exposed.","example":[{"id":907,"name":"Default location","is_ware_house":"1","enable_menu_checkout":null,"payment_provider":null,"ngenius_terminal_id":null,"ngenius_merchant_id":null,"invoice_service_version":"v1"},{"id":908,"name":"Johar Town","is_ware_house":"1","enable_menu_checkout":null,"payment_provider":null,"ngenius_terminal_id":null,"ngenius_merchant_id":null,"invoice_service_version":"v1"},{"id":909,"name":"Iqbal Town","is_ware_house":"1","enable_menu_checkout":null,"payment_provider":null,"ngenius_terminal_id":null,"ngenius_merchant_id":null,"invoice_service_version":"v1"},{"id":910,"name":"Bahira Town","is_ware_house":"0","enable_menu_checkout":null,"payment_provider":null,"ngenius_terminal_id":null,"ngenius_merchant_id":null,"invoice_service_version":"v1"}]},"parameters":{"type":"object","properties":{},"required":[]},"module":"location_business","safety":"read"},{"name":"get_location","description":"GET /api/locations/:id. Why: The full row of ONE branch — every column list_locations shows plus the integration ids (otter, deliverect, grubtech flags). Read it before update_location: that tool overwrites name, isWareHouse, enable_menu_checkout, payment_provider and both N-Genius ids, so you must send the current values of the ones you are not changing. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_location\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/locations/:id","why":"The full row of ONE branch — every column list_locations shows plus the integration ids (otter, deliverect, grubtech flags). Read it before update_location: that tool overwrites name, isWareHouse, enable_menu_checkout, payment_provider and both N-Genius ids, so you must send the current values of the ones you are not changing.","response":{"description":"A ONE-element array [ row ] (SELECT * … LIMIT 1); [] with HTTP 200 when the id does not exist — there is no 404. Columns are whatever the locations table has in that environment (newer schemas add enable_limetray / allow_limetray_status_updates).","fields":{"[]":"array. [row] or [] when not found.","[0].id":"int. Location (branch) id. This is the loc / location_id every other tool takes.","[0].name":"string. Branch name (unique per tenant, case as stored).","[0].customer_id":"int. Tenant id.","[0].is_ware_house":"string. \"1\" = branch is also a warehouse (offered as a stock source in stock order requests), \"0\" = not; NULL when never set. Stored as a string.","[0].is_primary":"string or null. Legacy primary-branch marker; no dashboard flow sets it. NOT USED.","[0].otter_location_id":"string or null. Otter integration store id; null = not linked.","[0].deliverect_location_id":"string or null. Deliverect integration location id; null = not linked.","[0].enable_menu_checkout":"int or null. 1 = QR/online menu checkout enabled for this branch, 0 = disabled, null = never set (treat as 0). Not edited by the dashboard location form.","[0].invoice_service_version":"string. \"v1\" (default) or \"v2\" — which invoice-service pipeline the branch uses.","[0].payment_provider":"string or null. Online payment gateway id for pay-at-table / QR payments (dashboard Payment Providers page values e.g. \"telr\", \"montypay\"); null = none.","[0].ngenius_terminal_id":"string or null. Network International N-Genius card terminal id (TID); null = no device.","[0].ngenius_merchant_id":"string or null. N-Genius merchant id (MID); null = no device.","[0].enable_grubtech":"int. 1 = Grubtech aggregator integration on for this branch, 0 = off.","[0].allow_grubtech_status_updates":"int. 1 = Grubtech may push order status updates, 0 = not.","[0].enable_limetray":"int. Only on schemas that have it: 1 = LimeTray integration on, 0 = off.","[0].allow_limetray_status_updates":"int. Only on schemas that have it: 1 = LimeTray may push status updates."},"master_rules":["Same row on master and non-master tenants; master-ness lives on the tenant/user (is_master_locations_enabled), not on the location.","Business profile (info_name, info_address, info_phone, info_email, info_vat_no, logo_image), currency, time_zone, opening_hour/closing_hour are per-branch config rows — get_business_info / get_pos_configs with loc = this id."],"caveats":"Unwrap [0]. An empty array means the id does not exist. No tenant filter on the server; MCP's allow_locations check is what keeps you on your own branches.","example":[{"id":907,"name":"Default location","customer_id":437,"is_ware_house":"1","is_primary":null,"otter_location_id":null,"deliverect_location_id":null,"enable_menu_checkout":null,"invoice_service_version":"v1","payment_provider":null,"ngenius_terminal_id":null,"ngenius_merchant_id":null,"enable_grubtech":0,"allow_grubtech_status_updates":0}]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Location id (list_locations[].id)."}},"required":["id"]},"module":"location_business","safety":"read"},{"name":"get_business_info","description":"GET /api/config/business-info. Why: The branch's business profile — what the receipt header prints (name, address, VAT No, logo) plus contact, legal name, delivery area and T&C. Read it before create_business_info / update_business_info and to check what a receipt will show. Pass strict=\"1\": without it an EMPTY branch silently returns ANOTHER branch's profile. Returns rows { id, name, value }; name is one of: info_name, legal_name, info_address, info_phone, info_email, info_vat_no, logo_image, license_image, location_latitude, location_longitude, polygon_coordinates, area_coordinates, receipt_footer_text, reciept_header_text (sic), currency, terms_and_conditions. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_business_info\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/config/business-info","why":"The branch's business profile — what the receipt header prints (name, address, VAT No, logo) plus contact, legal name, delivery area and T&C. Read it before create_business_info / update_business_info and to check what a receipt will show. Pass strict=\"1\": without it an EMPTY branch silently returns ANOTHER branch's profile.","response":{"description":"BARE JSON ARRAY of { id, value, name } for these keys only: info_vat_no, logo_image, info_name, legal_name, info_address, info_phone, info_email, receipt_footer_text, reciept_header_text, currency, location_latitude, location_longitude, polygon_coordinates, area_coordinates, license_image, terms_and_conditions. Fallback (no strict): when none of the branch's rows has a non-empty value, the server returns the session user's branch rows, then the tenant rows (location_id 0) — the rows carry NO location_id, so you cannot tell. Live: loc=908 returned branch 907's profile; with strict=1 it returned []. 401 when not authenticated.","fields":{"[]":"array. One row per key that EXISTS for the branch (keys never saved are absent; \"\" = saved empty). Unordered (id order in practice).","[].id":"int. config row id. NOT USED.","[].name":"string. One of: info_name (trading name, receipt), legal_name (registered name), info_address, info_phone, info_email, info_vat_no (TRN, receipt \"VAT No#\"), logo_image (receipt logo file/URL), license_image, location_latitude, location_longitude, polygon_coordinates, area_coordinates, receipt_footer_text, reciept_header_text, currency, terms_and_conditions. Meaning of each: config_keys / rules.","[].value":"string. Stored value (numbers are strings: parse vat, exchange_rate, decimal_points). \"\" = not set."},"master_rules":["Storage model: business info and settings are the SAME table `config` (id, name varchar, value text, customer_id, location_id) — one row per (tenant, branch, key), values always strings, no unique index (duplicates possible; the first row wins). Master tenants keep a full set per branch; a new branch starts EMPTY (nothing is inherited). location_id 0 = tenant-wide rows (integrations; business-info read fallback).","Receipt (web-pos, format1): logo_image → info_name → info_address → \"VAT No#\" info_vat_no come from THIS branch profile (via get_user_extras business_config); below it the order's BRAND block (brand_name, brand_address, brand_email, brand_phone from list_selling_brands; hidden when the brand is named \"unify brand\"). Both print — neither replaces the other.","Only 12 of these keys are writable with create_business_info / update_business_info: info_name, legal_name, info_address, info_phone, info_email, info_vat_no, logo_image, license_image, location_latitude, location_longitude, polygon_coordinates, area_coordinates. receipt_footer_text, reciept_header_text, currency and terms_and_conditions are read here but written with save_config.","Key info_name — string. Trading name of the branch shown on receipts. Default: missing → no name on receipt.","Key legal_name — string. Registered company name. Default: missing → get_user_extras sends null.","Key info_address — string. Postal address printed under the name. Default: missing → no address.","Key info_phone — string. Contact phone. Default: missing → none.","Key info_email — string. Contact e-mail. Default: missing → none.","Key info_vat_no — string. Tax registration number (TRN). Default: missing → no VAT No line.","Key logo_image — string (uploaded file name or URL). Receipt / document logo. Set only to an existing file name or https URL. Default: missing → no logo.","Key license_image — string (uploaded file name or URL). Trade-license document. Default: missing → none.","Key location_latitude — string (decimal degrees). Branch latitude. Default: missing → no map pin.","Key location_longitude — string (decimal degrees). Branch longitude. Default: missing → no map pin.","Key polygon_coordinates — string \"lat,lng;lat,lng;lat,lng;lat,lng\" (4 points). Delivery / service area polygon. Saved by delete + insert (always one row). Default: missing → no delivery area.","Key area_coordinates — string (same format as polygon_coordinates). Copy of polygon_coordinates. Default: missing → none."],"caveats":"Keys never saved are absent (live branch 907 has no legal_name / latitude / polygon rows). Use get_location_config for the operational keys (vat, time_zone, hours…). logo_image is a stored file name; get_user_extras.business_logo is the resolved URL.","example":[{"id":9993,"value":"Example Cafe","name":"info_name"},{"id":9994,"value":"Shop 1, Example Street","name":"info_address"},{"id":9995,"value":"info@example-cafe.test","name":"info_email"},{"id":9996,"value":"100000000000003","name":"info_vat_no"},{"id":9997,"value":"1762167059218-example-logo.png","name":"logo_image"},{"id":10103,"value":"","name":"currency"},{"id":10237,"value":"+971500000000","name":"info_phone"},{"id":11174,"value":"<ul><li>Optional note</li></ul>","name":"receipt_footer_text"}]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"ONE branch id (list_locations)."}},"required":[]},"module":"location_business","safety":"read"},{"name":"get_location_config","description":"GET /api/config/all. Why: Read a branch's settings exactly as the dashboard Settings page shows them: VAT %, currency, decimals, time zone, business hours, exchange rate, receipt texts, POS flags, plus the main business-info keys. Use it to check a branch before selling (sync_invoice needs vat, decimal_points, time_zone) and before save_config. Branch rows only — no fallback. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_location_config\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/config/all","why":"Read a branch's settings exactly as the dashboard Settings page shows them: VAT %, currency, decimals, time zone, business hours, exchange rate, receipt texts, POS flags, plus the main business-info keys. Use it to check a branch before selling (sync_invoice needs vat, decimal_points, time_zone) and before save_config. Branch rows only — no fallback.","response":{"description":"BARE JSON ARRAY of { id, value, name } — rows of THIS branch (location_id = loc exactly, no tenant / other-branch fallback) whose name is one of: vat, info_name, info_address, info_phone, info_email, max_order_time, receipt_footer_text, reciept_header_text, currency, is_printer_mpop, is_done_confirmation, is_plate_number, is_shift_enabled, time_zone, time_zone_code, opening_hour, closing_hour, decimal_points, exchange_rate, Product code types, info_vat_no, logo_image, email_time, license_image, delivery_time, terms_and_conditions. 401 when not authenticated.","fields":{"[]":"array. One row per key that EXISTS for the branch (keys never saved are absent; \"\" = saved empty). Unordered (id order in practice).","[].id":"int. config row id. NOT USED.","[].name":"string. Config key — see config_keys / the key rules below.","[].value":"string. Stored value (numbers are strings: parse vat, exchange_rate, decimal_points). \"\" = not set."},"master_rules":["Storage model: business info and settings are the SAME table `config` (id, name varchar, value text, customer_id, location_id) — one row per (tenant, branch, key), values always strings, no unique index (duplicates possible; the first row wins). Master tenants keep a full set per branch; a new branch starts EMPTY (nothing is inherited). location_id 0 = tenant-wide rows (integrations; business-info read fallback).","Missing key = never saved for this branch → the consumer's default applies (listed per key). Live: new branches 910, 1117, 1131 return []; 909 has only vat/hours/exchange_rate/max_order_time.","Before selling on a branch make sure vat, decimal_points (else money rounds to whole units), time_zone and opening_hour are set; fix with save_config.","Same rows as get_pos_configs (that one returns every key of the branch, with customer_id/location_id); get_user_extras reads a subset for the SESSION user's branch only.","Full dictionary: top-level config_keys in the catalog.","Key vat — string (number, percent) [\"0\"…\"100\" (decimals allowed, e.g. \"5\", \"7.5\"); \"-1\" legacy]. Flat VAT % of the branch, prices are VAT-INCLUSIVE. \"5\" = 5 %, \"0\" = no VAT. \"-1\" = legacy 'VAT per product' flag: web-pos does NOT understand it (Number(\"-1\") → negative VAT) — never set -1. Default: missing → web-pos uses 0 (no VAT).","Key currency — string (ISO 4217 code) [e.g. \"AED\", \"PKR\", \"USD\", \"SAR\"]. Currency code shown next to money. Display only — no conversion. Default: missing or \"\" → web-pos \"AED\"; some PDFs \"$\".","Key decimal_points — string (integer) [\"0\"…\"8\"]. Money rounding precision. Set it explicitly (usually \"2\") on every branch that sells. Default: missing → get_user_extras returns decimal_points 0 (NOT 2) → web-pos rounds money to WHOLE units.","Key time_zone — string (UTC offset \"±HH:MM\") [\"-12:00\"…\"+14:00\"]. Branch UTC offset. Must be \"+HH:MM\"/\"-HH:MM\" (MySQL CONVERT_TZ format). Not DST-aware — re-save after DST changes. Default: missing → +04:00 (web-pos, business hours).","Key time_zone_code — string (IANA zone name). Human zone name, e.g. \"Asia/Dubai\", \"Asia/Karachi\". Keep it consistent with time_zone. Default: missing → web-pos uses time_zone; default \"Arabian Standard Time\".","Key opening_hour — string (\"HH:mm\", 24 h). Start of the business day in branch local time. The business day runs opening_hour → opening_hour next day. Default: missing → 05:00 (business hours / reports); get_user_extras sends \"00:00\"-based value.","Key closing_hour — string (\"HH:mm\", 24 h). End of trading in local time; may be earlier than opening_hour (runs past midnight, e.g. 10:00 → 08:00). Does NOT cut the business day. Default: missing → web-pos 11:55 PM.","Key exchange_rate — string (number). Local currency units per 1 USD, e.g. \"3.67\" for AED. Default: missing → 0 (no USD tender).","Key max_order_time — string (integer minutes). Target minutes for an order on the kitchen screen. Default: missing → none.","Key is_printer_mpop — string flag [\"0\" | \"1\"]. 1 = print on a Star mPOP printer. Default: missing → \"0\".","Key is_plate_number — string flag [\"0\" | \"1\"]. 1 = ask for a plate/table number on orders. Default: missing → \"0\".","Key is_done_confirmation — string flag [\"0\" | \"1\"]. 1 = confirm before marking an order done. Default: missing → \"0\".","Key is_shift_enabled — string flag [\"0\" | \"1\"]. 1 = cashiers must open a shift before selling. Default: missing → 1 (enabled).","Key receipt_footer_text — string (plain text, lines split by \\n; legacy value is <ul><li>…</li></ul> HTML). Text printed at the bottom of the receipt (thank-you, return policy…). Default: missing → no footer.","Key reciept_header_text — string (HTML <ul><li>line</li>…</ul>). Extra header lines on the receipt. Key is misspelled on the server (reciept) — use this exact spelling. Default: missing → no extra header.","Key email_time — string (\"HH:mm\", 24 h). Local time the daily sales summary e-mail is sent. Default: missing → no scheduled e-mail.","Key delivery_time — string (integer minutes) [\"0\"…\"1440\"]. Promised delivery time in minutes. Default: missing → none.","Key terms_and_conditions — string (plain text, ≤ 20000 chars). Terms printed on Purchase Order PDFs of this branch. Not on sales receipts. Default: missing → none.","Key Product code types — string [\"EAN-13\" | \"QR Code\"]. Barcode symbology for product codes. Key contains spaces. Default: missing → none.","Key info_name — string. Trading name of the branch shown on receipts. Default: missing → no name on receipt.","Key legal_name — string. Registered company name. Default: missing → get_user_extras sends null.","Key info_address — string. Postal address printed under the name. Default: missing → no address.","Key info_phone — string. Contact phone. Default: missing → none.","Key info_email — string. Contact e-mail. Default: missing → none.","Key info_vat_no — string. Tax registration number (TRN). Default: missing → no VAT No line.","Key logo_image — string (uploaded file name or URL). Receipt / document logo. Set only to an existing file name or https URL. Default: missing → no logo.","Other keys that may exist: kiply_* / crossval_* (tenant rows at location_id 0, integrations), fixed_cost_selected, lat1–lat5 / lng1–lng5 (legacy area), deliverectToken (internal) — informational, never write them. pincode and is_sms_enable are NOT config keys (table `configuration`, see get_user_extras)."],"caveats":"Do not rely on any names filter: the server's names param crashes the backend when sent as a single string (MCP always sends strings), so this tool has none — filter the array yourself. Order is not guaranteed; duplicates of a key may exist (use the first).","example":[{"id":9905,"value":"+05:00","name":"time_zone"},{"id":9943,"value":"5","name":"vat"},{"id":9993,"value":"Example Cafe","name":"info_name"},{"id":9994,"value":"Shop 1, Example Street","name":"info_address"},{"id":9995,"value":"info@example-cafe.test","name":"info_email"},{"id":9996,"value":"100000000000003","name":"info_vat_no"},{"id":9997,"value":"1762167059218-example-logo.png","name":"logo_image"},{"id":10101,"value":"10:00","name":"opening_hour"},{"id":10102,"value":"08:00","name":"closing_hour"},{"id":10103,"value":"","name":"currency"},{"id":10237,"value":"+971500000000","name":"info_phone"},{"id":10977,"value":"Asia/Karachi","name":"time_zone_code"},{"id":11174,"value":"<ul><li>Optional note</li></ul>","name":"receipt_footer_text"},{"id":11578,"value":"12","name":"exchange_rate"},{"id":11583,"value":"20","name":"max_order_time"}]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"ONE branch id (list_locations)."}},"required":[]},"module":"location_business","safety":"read"},{"name":"get_currency","description":"GET /api/config/currency. Why: Currency code of one branch (display only). Same route as get_config_value with name=currency. Pass loc — without a branch the server returns the first currency row of ANY branch. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_currency\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/config/currency","why":"Currency code of one branch (display only). Same route as get_config_value with name=currency. Pass loc — without a branch the server returns the first currency row of ANY branch.","response":{"description":"{ name, value } of the branch's currency row, or {} when the branch has none. Live branch 907: { name: \"currency\", value: \"\" } (saved empty).","fields":{"name":"string. Always \"currency\". Absent when the object is {}.","value":"string. ISO code (\"AED\", \"PKR\"…). \"\" or {} = not set → web-pos shows AED."},"master_rules":["Effective POS currency = this value, else get_user_extras business_config.currency.value, else \"AED\". Change it with save_config name=currency.","Key currency — string (ISO 4217 code) [e.g. \"AED\", \"PKR\", \"USD\", \"SAR\"]. Currency code shown next to money. Display only — no conversion. Default: missing or \"\" → web-pos \"AED\"; some PDFs \"$\"."],"caveats":"No conversion anywhere: changing the code does not change prices.","example":{"name":"currency","value":""}},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"ONE branch id (list_locations)."}},"required":[]},"module":"location_business","safety":"read"},{"name":"get_config_value","description":"GET /api/config/:name. Why: Read ONE config key of one branch (any key, including ones get_location_config does not list). Use before save_config to see the current value. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_config_value\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/config/:name","why":"Read ONE config key of one branch (any key, including ones get_location_config does not list). Use before save_config to see the current value.","response":{"description":"{ name, value } of the first matching row (branch = loc), or {} when the branch has no such key (no fallback). Live: vat?loc=907 → { name: \"vat\", value: \"5\" }; unknown key → {}.","fields":{"name":"string. The key you asked for. Absent when {}.","value":"string. Stored value; \"\" = saved empty. {} = never saved → the key's default applies."},"master_rules":["Storage model: business info and settings are the SAME table `config` (id, name varchar, value text, customer_id, location_id) — one row per (tenant, branch, key), values always strings, no unique index (duplicates possible; the first row wins). Master tenants keep a full set per branch; a new branch starts EMPTY (nothing is inherited). location_id 0 = tenant-wide rows (integrations; business-info read fallback).","Key vat — string (number, percent) [\"0\"…\"100\" (decimals allowed, e.g. \"5\", \"7.5\"); \"-1\" legacy]. Flat VAT % of the branch, prices are VAT-INCLUSIVE. \"5\" = 5 %, \"0\" = no VAT. \"-1\" = legacy 'VAT per product' flag: web-pos does NOT understand it (Number(\"-1\") → negative VAT) — never set -1. Default: missing → web-pos uses 0 (no VAT).","Key currency — string (ISO 4217 code) [e.g. \"AED\", \"PKR\", \"USD\", \"SAR\"]. Currency code shown next to money. Display only — no conversion. Default: missing or \"\" → web-pos \"AED\"; some PDFs \"$\".","Key decimal_points — string (integer) [\"0\"…\"8\"]. Money rounding precision. Set it explicitly (usually \"2\") on every branch that sells. Default: missing → get_user_extras returns decimal_points 0 (NOT 2) → web-pos rounds money to WHOLE units.","Key time_zone — string (UTC offset \"±HH:MM\") [\"-12:00\"…\"+14:00\"]. Branch UTC offset. Must be \"+HH:MM\"/\"-HH:MM\" (MySQL CONVERT_TZ format). Not DST-aware — re-save after DST changes. Default: missing → +04:00 (web-pos, business hours).","Key time_zone_code — string (IANA zone name). Human zone name, e.g. \"Asia/Dubai\", \"Asia/Karachi\". Keep it consistent with time_zone. Default: missing → web-pos uses time_zone; default \"Arabian Standard Time\".","Key opening_hour — string (\"HH:mm\", 24 h). Start of the business day in branch local time. The business day runs opening_hour → opening_hour next day. Default: missing → 05:00 (business hours / reports); get_user_extras sends \"00:00\"-based value.","Key closing_hour — string (\"HH:mm\", 24 h). End of trading in local time; may be earlier than opening_hour (runs past midnight, e.g. 10:00 → 08:00). Does NOT cut the business day. Default: missing → web-pos 11:55 PM.","Key exchange_rate — string (number). Local currency units per 1 USD, e.g. \"3.67\" for AED. Default: missing → 0 (no USD tender).","Key max_order_time — string (integer minutes). Target minutes for an order on the kitchen screen. Default: missing → none.","Key is_printer_mpop — string flag [\"0\" | \"1\"]. 1 = print on a Star mPOP printer. Default: missing → \"0\".","Key is_plate_number — string flag [\"0\" | \"1\"]. 1 = ask for a plate/table number on orders. Default: missing → \"0\".","Key is_done_confirmation — string flag [\"0\" | \"1\"]. 1 = confirm before marking an order done. Default: missing → \"0\".","Key is_shift_enabled — string flag [\"0\" | \"1\"]. 1 = cashiers must open a shift before selling. Default: missing → 1 (enabled).","Key receipt_footer_text — string (plain text, lines split by \\n; legacy value is <ul><li>…</li></ul> HTML). Text printed at the bottom of the receipt (thank-you, return policy…). Default: missing → no footer.","Key reciept_header_text — string (HTML <ul><li>line</li>…</ul>). Extra header lines on the receipt. Key is misspelled on the server (reciept) — use this exact spelling. Default: missing → no extra header.","Key email_time — string (\"HH:mm\", 24 h). Local time the daily sales summary e-mail is sent. Default: missing → no scheduled e-mail.","Key delivery_time — string (integer minutes) [\"0\"…\"1440\"]. Promised delivery time in minutes. Default: missing → none.","Key terms_and_conditions — string (plain text, ≤ 20000 chars). Terms printed on Purchase Order PDFs of this branch. Not on sales receipts. Default: missing → none.","Key Product code types — string [\"EAN-13\" | \"QR Code\"]. Barcode symbology for product codes. Key contains spaces. Default: missing → none.","Key info_name — string. Trading name of the branch shown on receipts. Default: missing → no name on receipt.","Key legal_name — string. Registered company name. Default: missing → get_user_extras sends null.","Key info_address — string. Postal address printed under the name. Default: missing → no address.","Key info_phone — string. Contact phone. Default: missing → none.","Key info_email — string. Contact e-mail. Default: missing → none.","Key info_vat_no — string. Tax registration number (TRN). Default: missing → no VAT No line.","Key logo_image — string (uploaded file name or URL). Receipt / document logo. Set only to an existing file name or https URL. Default: missing → no logo.","Key license_image — string (uploaded file name or URL). Trade-license document. Default: missing → none.","Key location_latitude — string (decimal degrees). Branch latitude. Default: missing → no map pin.","Key location_longitude — string (decimal degrees). Branch longitude. Default: missing → no map pin.","Key polygon_coordinates — string \"lat,lng;lat,lng;lat,lng;lat,lng\" (4 points). Delivery / service area polygon. Saved by delete + insert (always one row). Default: missing → no delivery area.","Key area_coordinates — string (same format as polygon_coordinates). Copy of polygon_coordinates. Default: missing → none.","Other keys that may exist: kiply_* / crossval_* (tenant rows at location_id 0, integrations), fixed_cost_selected, lat1–lat5 / lng1–lng5 (legacy area), deliverectToken (internal) — informational, never write them. pincode and is_sms_enable are NOT config keys (table `configuration`, see get_user_extras)."],"caveats":"Keys are case-sensitive in practice; spaces are fine (URL-encoded). The route cannot read table `configuration` (pincode, is_sms_enable).","example":{"name":"vat","value":"5"}},"parameters":{"type":"object","properties":{"name":{"type":"string","description":"Config key, exact spelling and case (e.g."},"loc":{"type":"string","description":"ONE branch id (list_locations)."}},"required":["name"]},"module":"location_business","safety":"read"},{"name":"save_config","description":"POST /api/config. Why: Set ONE setting of ONE branch — exactly what each card of the dashboard Settings page saves (Tax, Currency, Decimal Points, Time Zone, Opening Hours, Base Price, receipt texts, POS flags…). Upsert by (tenant, branch, name); other keys are untouched. Call once per key and per branch (the dashboard saves time_zone + time_zone_code, and opening_hour + closing_hour, as two calls). Read the current value first (get_config_value / get_location_config) and confirm with the user. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"save_config\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/config","why":"Set ONE setting of ONE branch — exactly what each card of the dashboard Settings page saves (Tax, Currency, Decimal Points, Time Zone, Opening Hours, Base Price, receipt texts, POS flags…). Upsert by (tenant, branch, name); other keys are untouched. Call once per key and per branch (the dashboard saves time_zone + time_zone_code, and opening_hour + closing_hour, as two calls). Read the current value first (get_config_value / get_location_config) and confirm with the user.","response":{"description":"Echo of what was written: { name, value, customer_id, location_id } (sent with res.end — no JSON content-type; not a DB re-read). DB errors are only logged: the echo comes back even if nothing was saved — verify with get_config_value.","fields":{"name":"string. The key.","value":"string. The value you sent.","customer_id":"int. Tenant id (from the session).","location_id":"string | int. Branch written (= loc)."},"master_rules":["Semantics: SELECT name+branch → UPDATE value when a row exists (ALL duplicates updated), else INSERT. Partial by nature: one key per call, nothing else changes. No validation of name or value — a typo creates a new useless key.","Side effects: none server-side (no cache, no push). web-pos picks the change up at the next login or POS 'Sync' (get_pos_configs / get_user_extras are re-read then); reports use it immediately.","Master tenants: settings are per branch. To apply to several branches call once per loc (the dashboard has a bulk route that is not exposed).","Effects on selling: vat → VAT of every new order line (sync_invoice vat / vat_pct); decimal_points → rounding of every amount; time_zone → seconds_from_gmt and which business day an order belongs to; opening_hour → business-day cut (get_today_sales, reports). Existing orders are not recalculated.","Do NOT write vat \"-1\" (per-product VAT flag unsupported by web-pos), integration keys, lat*/lng*, or anything in the 'Other keys' line.","Key vat — string (number, percent) [\"0\"…\"100\" (decimals allowed, e.g. \"5\", \"7.5\"); \"-1\" legacy]. Flat VAT % of the branch, prices are VAT-INCLUSIVE. \"5\" = 5 %, \"0\" = no VAT. \"-1\" = legacy 'VAT per product' flag: web-pos does NOT understand it (Number(\"-1\") → negative VAT) — never set -1. Default: missing → web-pos uses 0 (no VAT).","Key currency — string (ISO 4217 code) [e.g. \"AED\", \"PKR\", \"USD\", \"SAR\"]. Currency code shown next to money. Display only — no conversion. Default: missing or \"\" → web-pos \"AED\"; some PDFs \"$\".","Key decimal_points — string (integer) [\"0\"…\"8\"]. Money rounding precision. Set it explicitly (usually \"2\") on every branch that sells. Default: missing → get_user_extras returns decimal_points 0 (NOT 2) → web-pos rounds money to WHOLE units.","Key time_zone — string (UTC offset \"±HH:MM\") [\"-12:00\"…\"+14:00\"]. Branch UTC offset. Must be \"+HH:MM\"/\"-HH:MM\" (MySQL CONVERT_TZ format). Not DST-aware — re-save after DST changes. Default: missing → +04:00 (web-pos, business hours).","Key time_zone_code — string (IANA zone name). Human zone name, e.g. \"Asia/Dubai\", \"Asia/Karachi\". Keep it consistent with time_zone. Default: missing → web-pos uses time_zone; default \"Arabian Standard Time\".","Key opening_hour — string (\"HH:mm\", 24 h). Start of the business day in branch local time. The business day runs opening_hour → opening_hour next day. Default: missing → 05:00 (business hours / reports); get_user_extras sends \"00:00\"-based value.","Key closing_hour — string (\"HH:mm\", 24 h). End of trading in local time; may be earlier than opening_hour (runs past midnight, e.g. 10:00 → 08:00). Does NOT cut the business day. Default: missing → web-pos 11:55 PM.","Key exchange_rate — string (number). Local currency units per 1 USD, e.g. \"3.67\" for AED. Default: missing → 0 (no USD tender).","Key max_order_time — string (integer minutes). Target minutes for an order on the kitchen screen. Default: missing → none.","Key is_printer_mpop — string flag [\"0\" | \"1\"]. 1 = print on a Star mPOP printer. Default: missing → \"0\".","Key is_plate_number — string flag [\"0\" | \"1\"]. 1 = ask for a plate/table number on orders. Default: missing → \"0\".","Key is_done_confirmation — string flag [\"0\" | \"1\"]. 1 = confirm before marking an order done. Default: missing → \"0\".","Key is_shift_enabled — string flag [\"0\" | \"1\"]. 1 = cashiers must open a shift before selling. Default: missing → 1 (enabled).","Key receipt_footer_text — string (plain text, lines split by \\n; legacy value is <ul><li>…</li></ul> HTML). Text printed at the bottom of the receipt (thank-you, return policy…). Default: missing → no footer.","Key reciept_header_text — string (HTML <ul><li>line</li>…</ul>). Extra header lines on the receipt. Key is misspelled on the server (reciept) — use this exact spelling. Default: missing → no extra header.","Key email_time — string (\"HH:mm\", 24 h). Local time the daily sales summary e-mail is sent. Default: missing → no scheduled e-mail.","Key delivery_time — string (integer minutes) [\"0\"…\"1440\"]. Promised delivery time in minutes. Default: missing → none.","Key terms_and_conditions — string (plain text, ≤ 20000 chars). Terms printed on Purchase Order PDFs of this branch. Not on sales receipts. Default: missing → none.","Key Product code types — string [\"EAN-13\" | \"QR Code\"]. Barcode symbology for product codes. Key contains spaces. Default: missing → none.","Other keys that may exist: kiply_* / crossval_* (tenant rows at location_id 0, integrations), fixed_cost_selected, lat1–lat5 / lng1–lng5 (legacy area), deliverectToken (internal) — informational, never write them. pincode and is_sms_enable are NOT config keys (table `configuration`, see get_user_extras)."],"caveats":"Values are stored verbatim (text column): send the exact format; the server does not trim, parse or range-check. The dashboard itself does: vat max(0, number); flags \"1\"/\"0\"; time_zone = offset of the chosen IANA zone.","example":{"name":"decimal_points","value":"2","customer_id":437,"location_id":"907"}},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"ONE branch id (list_locations)."},"name":{"type":"string","description":"Config key, exact spelling (see config_keys): e.g."},"value":{"type":"string","description":"New value AS A STRING in the key's format (vat \"5\", decimal_points \"2\", time_zone \"+04:00\", opening_hour \"06:00\", flags \"0\"/\"1\")."}},"required":["name","value"]},"module":"location_business","safety":"write"},{"name":"create_business_info","description":"POST /api/config/business-info. Why: Save a branch's business profile (receipt name, address, VAT No, logo, contact, delivery area). POST and PUT run the SAME handler (update_business_info is identical): an upsert of each field you send. Send only the fields to change; fields you omit are untouched. Read get_business_info (strict=\"1\") first and confirm with the user. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_business_info\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/config/business-info","why":"Save a branch's business profile (receipt name, address, VAT No, logo, contact, delivery area). POST and PUT run the SAME handler (update_business_info is identical): an upsert of each field you send. Send only the fields to change; fields you omit are untouched. Read get_business_info (strict=\"1\") first and confirm with the user.","response":{"description":"200 { msg: \"Business info saved successfully\" } (no data echo). 400 { error: \"Location ID is required\" } (never via MCP — loc is injected); 500 { error: \"Failed to save business info\" } (earlier fields may already be saved).","fields":{"msg":"string. \"Business info saved successfully\" on success.","error":"string. Present only on 400/500."},"master_rules":["Writable fields = exactly these 12: info_name, legal_name, info_address, info_phone, info_email, info_vat_no, logo_image, license_image, location_latitude, location_longitude, polygon_coordinates, area_coordinates. Any other body key (currency, receipt_footer_text, reciept_header_text, terms_and_conditions, vat…) is SILENTLY IGNORED — use save_config for those.","Per field sent: UPDATE the (tenant, branch, key) row, INSERT if none; value = sent value or \"\" for null/empty. polygon_coordinates is deleted and re-inserted. Row-by-row, no transaction.","Scope: this branch only (loc). A branch without its own profile shows another branch's profile in get_business_info unless strict=\"1\" — saving here gives it its own rows (the fallback then stops).","Where it shows: info_name, info_address, info_vat_no and logo_image print on every web-pos receipt of the branch (after the next POS login/Sync, via get_user_extras); phone/e-mail/legal name on invoice and PO PDFs. The order's brand block (list_selling_brands) prints separately under it.","No server validation (the dashboard requires info_name, info_email, info_address). Never invent phone numbers, e-mails or TRNs — ask the user.","Side effects: none (no cache/push).","Key info_name — string. Trading name of the branch shown on receipts. Default: missing → no name on receipt.","Key legal_name — string. Registered company name. Default: missing → get_user_extras sends null.","Key info_address — string. Postal address printed under the name. Default: missing → no address.","Key info_phone — string. Contact phone. Default: missing → none.","Key info_email — string. Contact e-mail. Default: missing → none.","Key info_vat_no — string. Tax registration number (TRN). Default: missing → no VAT No line.","Key logo_image — string (uploaded file name or URL). Receipt / document logo. Set only to an existing file name or https URL. Default: missing → no logo.","Key license_image — string (uploaded file name or URL). Trade-license document. Default: missing → none.","Key location_latitude — string (decimal degrees). Branch latitude. Default: missing → no map pin.","Key location_longitude — string (decimal degrees). Branch longitude. Default: missing → no map pin.","Key polygon_coordinates — string \"lat,lng;lat,lng;lat,lng;lat,lng\" (4 points). Delivery / service area polygon. Saved by delete + insert (always one row). Default: missing → no delivery area.","Key area_coordinates — string (same format as polygon_coordinates). Copy of polygon_coordinates. Default: missing → none."],"caveats":"The dashboard form drops empty fields before sending, so clearing a field in the UI does not clear it; via MCP an explicit \"\" does. logo/license files must already be uploaded (dashboard); this tool stores only the name/URL.","example":{"msg":"Business info saved successfully"}},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"ONE branch id (list_locations)."},"info_name":{"type":"string","description":"Optional."},"legal_name":{"type":"string","description":"Optional."},"info_address":{"type":"string","description":"Optional."},"info_phone":{"type":"string","description":"Optional."},"info_email":{"type":"string","description":"Optional."},"info_vat_no":{"type":"string","description":"Optional."},"logo_image":{"type":"string","description":"Optional."},"license_image":{"type":"string","description":"Optional."},"location_latitude":{"type":"string","description":"Optional."},"location_longitude":{"type":"string","description":"Optional."},"polygon_coordinates":{"type":"string","description":"Optional."},"area_coordinates":{"type":"string","description":"Optional."}},"required":[]},"module":"location_business","safety":"write"},{"name":"update_business_info","description":"PUT /api/config/business-info. Why: Save a branch's business profile (receipt name, address, VAT No, logo, contact, delivery area). PUT and POST run the SAME handler (create_business_info is identical): an upsert of each field you send. Send only the fields to change; fields you omit are untouched. Read get_business_info (strict=\"1\") first and confirm with the user. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_business_info\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/config/business-info","why":"Save a branch's business profile (receipt name, address, VAT No, logo, contact, delivery area). PUT and POST run the SAME handler (create_business_info is identical): an upsert of each field you send. Send only the fields to change; fields you omit are untouched. Read get_business_info (strict=\"1\") first and confirm with the user.","response":{"description":"200 { msg: \"Business info saved successfully\" } (no data echo). 400 { error: \"Location ID is required\" } (never via MCP — loc is injected); 500 { error: \"Failed to save business info\" } (earlier fields may already be saved).","fields":{"msg":"string. \"Business info saved successfully\" on success.","error":"string. Present only on 400/500."},"master_rules":["Writable fields = exactly these 12: info_name, legal_name, info_address, info_phone, info_email, info_vat_no, logo_image, license_image, location_latitude, location_longitude, polygon_coordinates, area_coordinates. Any other body key (currency, receipt_footer_text, reciept_header_text, terms_and_conditions, vat…) is SILENTLY IGNORED — use save_config for those.","Per field sent: UPDATE the (tenant, branch, key) row, INSERT if none; value = sent value or \"\" for null/empty. polygon_coordinates is deleted and re-inserted. Row-by-row, no transaction.","Scope: this branch only (loc). A branch without its own profile shows another branch's profile in get_business_info unless strict=\"1\" — saving here gives it its own rows (the fallback then stops).","Where it shows: info_name, info_address, info_vat_no and logo_image print on every web-pos receipt of the branch (after the next POS login/Sync, via get_user_extras); phone/e-mail/legal name on invoice and PO PDFs. The order's brand block (list_selling_brands) prints separately under it.","No server validation (the dashboard requires info_name, info_email, info_address). Never invent phone numbers, e-mails or TRNs — ask the user.","Side effects: none (no cache/push).","Key info_name — string. Trading name of the branch shown on receipts. Default: missing → no name on receipt.","Key legal_name — string. Registered company name. Default: missing → get_user_extras sends null.","Key info_address — string. Postal address printed under the name. Default: missing → no address.","Key info_phone — string. Contact phone. Default: missing → none.","Key info_email — string. Contact e-mail. Default: missing → none.","Key info_vat_no — string. Tax registration number (TRN). Default: missing → no VAT No line.","Key logo_image — string (uploaded file name or URL). Receipt / document logo. Set only to an existing file name or https URL. Default: missing → no logo.","Key license_image — string (uploaded file name or URL). Trade-license document. Default: missing → none.","Key location_latitude — string (decimal degrees). Branch latitude. Default: missing → no map pin.","Key location_longitude — string (decimal degrees). Branch longitude. Default: missing → no map pin.","Key polygon_coordinates — string \"lat,lng;lat,lng;lat,lng;lat,lng\" (4 points). Delivery / service area polygon. Saved by delete + insert (always one row). Default: missing → no delivery area.","Key area_coordinates — string (same format as polygon_coordinates). Copy of polygon_coordinates. Default: missing → none."],"caveats":"The dashboard form drops empty fields before sending, so clearing a field in the UI does not clear it; via MCP an explicit \"\" does. logo/license files must already be uploaded (dashboard); this tool stores only the name/URL.","example":{"msg":"Business info saved successfully"}},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"ONE branch id (list_locations)."},"info_name":{"type":"string","description":"Optional."},"legal_name":{"type":"string","description":"Optional."},"info_address":{"type":"string","description":"Optional."},"info_phone":{"type":"string","description":"Optional."},"info_email":{"type":"string","description":"Optional."},"info_vat_no":{"type":"string","description":"Optional."},"logo_image":{"type":"string","description":"Optional."},"license_image":{"type":"string","description":"Optional."},"location_latitude":{"type":"string","description":"Optional."},"location_longitude":{"type":"string","description":"Optional."},"polygon_coordinates":{"type":"string","description":"Optional."},"area_coordinates":{"type":"string","description":"Optional."}},"required":[]},"module":"location_business","safety":"write"},{"name":"get_business_hours","description":"GET /api/config/business-hours. Why: The branch's BUSINESS DAY as a UTC window — the same opening_hour + time_zone config rule the POS uses for 'today' (get_today_sales, get_ol_info, list_kds_orders, daily reports). Use start_utc / end_utc to bound order or invoice queries for a date range in the branch's local business days. It says nothing about per-weekday open/closed times — that is get_location_days_hours. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_business_hours\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/config/business-hours","why":"The branch's BUSINESS DAY as a UTC window — the same opening_hour + time_zone config rule the POS uses for 'today' (get_today_sales, get_ol_info, list_kds_orders, daily reports). Use start_utc / end_utc to bound order or invoice queries for a date range in the branch's local business days. It says nothing about per-weekday open/closed times — that is get_location_days_hours.","response":{"description":"HTTP 200 { success: true, data }. start_utc = CONVERT_TZ(from + ' ' + opening_hour, time_zone → UTC); end_utc = CONVERT_TZ(to + 1 day + ' ' + opening_hour, time_zone → UTC). opening_hour and time_zone come from the branch's config rows (defaults \"05:00\" and \"+04:00\" when the row is missing); closing_hour is only echoed. Errors: 400 { success: false, message: \"customer_id and location_id are required\" }; 404 { success: false, message: \"Business hours not found for the specified customer and location\" } when the time zone cannot be converted; 500 { success: false, message: \"Internal server error\", error }.","fields":{"success":"boolean. true on 200.","data.start_utc":"string. \"YYYY-MM-DD HH:mm:ss\" in UTC (no Z). Start of the first business day.","data.end_utc":"string. \"YYYY-MM-DD HH:mm:ss\" in UTC. End (exclusive) of the last business day = next day's opening.","data.time_zone":"string. UTC offset like \"+04:00\", derived from the config time_zone through a zone-name lookup. When the config holds an offset (e.g. \"+05:00\") this comes back \"+00:00\" — do not trust it; read time_zone from get_pos_configs. The window itself is computed correctly.","data.opening_hour":"string. \"HH:mm\" business-day start (config opening_hour; \"05:00\" when unset).","data.closing_hour":"string. \"HH:mm\" config closing_hour (\"23:00\" when unset). Informational only: the window always runs opening_hour → next opening_hour; a closing_hour earlier than opening_hour means the branch closes after midnight."},"master_rules":["Same config model on master and non-master: opening_hour / closing_hour / time_zone are per-branch config rows (location_id = branch), set on the dashboard Settings > Opening hours card; get_pos_configs shows them raw.","The business day, not the calendar day, is what list_kds_orders ('today'), get_today_sales and get_ol_info use. A sale at 02:00 local with opening_hour 05:00 belongs to the previous business day.","Branches without config rows use the defaults (05:00, +04:00) — a new branch from create_location starts there."],"caveats":"The route itself needs customer_id; MCP adds it from the session. The per-weekday schedule (location_days_hours_config) is NOT consulted — a weekday marked off still returns a normal window.","example":{"success":true,"data":{"start_utc":"2026-09-25 05:00:00","end_utc":"2026-09-26 05:00:00","time_zone":"+00:00","opening_hour":"10:00","closing_hour":"08:00"}}},"parameters":{"type":"object","properties":{"location_id":{"type":"string","description":"Optional."},"from":{"type":"string","description":"Optional."},"to":{"type":"string","description":"Optional."}},"required":[]},"module":"location_business","safety":"read"},{"name":"get_location_days_hours","description":"GET /api/location-days-hours-config. Why: The branch's weekly OPERATING HOURS as set on the dashboard Business Info > Operating Hours tab: one row per weekday with open / close time or 'closed all day'. Use it to answer 'when is branch X open on Sunday' or before upsert_location_days_hours. It is independent from the business-day hours (opening_hour config → get_business_hours), which drive POS 'today' and reports. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_location_days_hours\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/location-days-hours-config","why":"The branch's weekly OPERATING HOURS as set on the dashboard Business Info > Operating Hours tab: one row per weekday with open / close time or 'closed all day'. Use it to answer 'when is branch X open on Sunday' or before upsert_location_days_hours. It is independent from the business-day hours (opening_hour config → get_business_hours), which drive POS 'today' and reports.","response":{"description":"Bare JSON array, ordered monday → sunday; only the days that were saved (0–7 rows, [] = never configured). Errors (JSON { message }): 400 \"Location ID is required\"; 403 \"Access denied to this location\" when the session user's allow_locations does not contain the branch; 500 \"Database error occurred\".","fields":{"[]":"array. One row per configured weekday. [] = no weekly schedule saved for this branch.","[].id":"int. Row id. NOT USED by the upsert (it matches rows by day).","[].customer_id":"int. Tenant id.","[].location_id":"int. Branch id.","[].day":"string. \"monday\" … \"sunday\" (lowercase).","[].open":"string or null. Opening time \"HH:mm\" (24 h, branch local time); null when is_off = 1 or not set.","[].close":"string or null. Closing time \"HH:mm\"; a value ≤ open means the branch closes after midnight (e.g. open \"12:00\" close \"00:00\" = noon to midnight); null when is_off = 1 or not set.","[].is_off":"int. 1 = closed all day, 0 = open between open and close.","[].created_at":"string. ISO timestamp the row was first saved.","[].updated_at":"string. ISO timestamp of the row insert (the column is not auto-updated on later edits)."},"master_rules":["Per branch, same on master and non-master. One time slot per day — no split shifts, no holidays, no date exceptions. '24 hours' has no special value (the dashboard would store open = close = \"00:00\").","Nothing in the POS backend reads these rows: they do not change the business day (get_business_hours), KDS 'today', reports or order acceptance. Only the dashboard displays them; external ordering apps may read them through this endpoint."],"caveats":"A day missing from the array is 'not configured', not 'closed'. Times are plain strings in the branch's local time (time_zone config); no validation that close > open.","example":[{"id":29,"customer_id":437,"location_id":909,"day":"monday","open":"12:00","close":"00:00","is_off":0,"created_at":"2025-10-03T05:50:48.000Z","updated_at":"2025-10-03T05:50:48.000Z"},{"id":30,"customer_id":437,"location_id":909,"day":"tuesday","open":"12:00","close":"00:00","is_off":0,"created_at":"2025-10-03T05:50:48.000Z","updated_at":"2025-10-03T05:50:48.000Z"},{"id":31,"customer_id":437,"location_id":909,"day":"wednesday","open":"12:00","close":"00:00","is_off":0,"created_at":"2025-10-03T05:50:48.000Z","updated_at":"2025-10-03T05:50:48.000Z"},{"id":32,"customer_id":437,"location_id":909,"day":"thursday","open":"12:00","close":"00:00","is_off":0,"created_at":"2025-10-03T05:50:48.000Z","updated_at":"2025-10-03T05:50:48.000Z"},{"id":33,"customer_id":437,"location_id":909,"day":"friday","open":"12:00","close":"00:00","is_off":0,"created_at":"2025-10-03T05:50:48.000Z","updated_at":"2025-10-03T05:50:48.000Z"},{"id":34,"customer_id":437,"location_id":909,"day":"saturday","open":"12:00","close":"00:00","is_off":0,"created_at":"2025-10-03T05:50:48.000Z","updated_at":"2025-10-03T05:50:48.000Z"},{"id":35,"customer_id":437,"location_id":909,"day":"sunday","open":null,"close":null,"is_off":1,"created_at":"2025-10-03T05:50:48.000Z","updated_at":"2025-10-03T05:50:48.000Z"}]},"parameters":{"type":"object","properties":{"location_id":{"type":"string","description":"ONE branch id — pass it."}},"required":[]},"module":"location_business","safety":"read"},{"name":"get_pos_configs","description":"GET /api/v2/config/get-all-configs. Why: Every config row (name/value) of ONE branch — the source web-pos uses at boot for the flat VAT % (key vat), currency (currency) and time zone (time_zone, else time_zone_code). Read it before building a sync_invoice order: vat → line/order vat and vat_pct, time_zone → seconds_from_gmt. Also holds business hours (opening_hour / closing_hour → get_today_sales, get_ol_info), receipt header keys and exchange_rate. Tax groups are out of scope. For a named subset use get_location_config / get_business_info. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_pos_configs\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/config/get-all-configs","why":"Every config row (name/value) of ONE branch — the source web-pos uses at boot for the flat VAT % (key vat), currency (currency) and time zone (time_zone, else time_zone_code). Read it before building a sync_invoice order: vat → line/order vat and vat_pct, time_zone → seconds_from_gmt. Also holds business hours (opening_hour / closing_hour → get_today_sales, get_ol_info), receipt header keys and exchange_rate. Tax groups are out of scope. For a named subset use get_location_config / get_business_info.","response":{"description":"{ status, message, data }. data = select * from config where customer_id = tenant and location_id = branch; unordered (id order in practice); values are STRINGS. Tenant-wide rows (location_id 0) are NOT included. Errors: 500 { status:false, message:\"Failed to fetch configs\" }; 401 when not authenticated.","fields":{"status":"boolean. true on success.","message":"string. \"Configs retrieved successfully\".","data":"array. Config rows of the branch. [] = none (then web-pos falls back to get_user_extras / defaults).","data[].id":"int. Config row id. NOT USED.","data[].name":"string. Config key (see rules for the keys that matter). USE: web-pos turns the rows into a { name: value } map.","data[].value":"string. Value as stored (numbers are strings, e.g. vat \"5\"; \"\" = not set).","data[].customer_id":"int. Tenant id.","data[].location_id":"int. Branch id (= loc)."},"master_rules":["web-pos boot (fetchAndStore.js 'Fetching location configs', params customer_id + location_id of the logged-in user) stores the rows in IndexedDB 'config'; setPreStates.js builds the map and puts it in businessConfig.location_config.","vat → flat VAT % for every order (web-pos: this key, else get_user_extras business_config.vat; \"\" → 0). sync_invoice BUILD 5 uses it: extractVat(g) = g − g/(1 + vat/100).","currency → currency code (else get_user_extras business_config.currency.value, else \"AED\"; \"\" counts as not set).","time_zone → branch UTC offset \"+04:00\" (also \"GMT+4\"); else time_zone_code (IANA or Windows name, e.g. \"Asia/Karachi\"); else +04:00. web-pos sends orders with time_zone \"GMT\" and seconds_from_gmt = that offset in seconds (sync_invoice).","opening_hour / closing_hour (\"HH:mm\") → business day (get_today_sales, get_ol_info; default 05:00). max_order_time → KDS target minutes. exchange_rate → secondary-currency rate. decimal_points → money decimals (web-pos reads it through get_user_extras data.decimal_points).","info_name, info_address, info_phone, info_email, info_vat_no, logo_image, receipt_footer_text, reciept_header_text (server spelling) → receipt header/footer; web-pos prints them from get_user_extras business_config.","Other keys may appear (is_shift_enabled, is_printer_mpop, polygon_coordinates, email_time, …); treat unknown keys as informational."],"caveats":"Values are strings: parse vat / exchange_rate / decimal_points. A key missing here is simply not set for that branch — get_location_config (/config/all) has no tenant-wide fallback either. The handler also accepts location_id / customer_id query params; the tool sends only loc.","example":{"status":true,"message":"Configs retrieved successfully","data":[{"id":9905,"name":"time_zone","value":"+05:00","customer_id":437,"location_id":907},{"id":9943,"name":"vat","value":"5","customer_id":437,"location_id":907},{"id":9993,"name":"info_name","value":"Example Cafe","customer_id":437,"location_id":907},{"id":9994,"name":"info_address","value":"Shop 1, Example Street","customer_id":437,"location_id":907},{"id":9995,"name":"info_email","value":"info@example-cafe.test","customer_id":437,"location_id":907},{"id":9996,"name":"info_vat_no","value":"100000000000003","customer_id":437,"location_id":907},{"id":9997,"name":"logo_image","value":"example-logo.png","customer_id":437,"location_id":907},{"id":10101,"name":"opening_hour","value":"10:00","customer_id":437,"location_id":907},{"id":10102,"name":"closing_hour","value":"08:00","customer_id":437,"location_id":907},{"id":10103,"name":"currency","value":"","customer_id":437,"location_id":907},{"id":10237,"name":"info_phone","value":"+971500000000","customer_id":437,"location_id":907},{"id":10977,"name":"time_zone_code","value":"Asia/Karachi","customer_id":437,"location_id":907},{"id":11174,"name":"receipt_footer_text","value":"<ul><li>Optional note</li></ul>","customer_id":437,"location_id":907},{"id":11578,"name":"exchange_rate","value":"12","customer_id":437,"location_id":907},{"id":11583,"name":"max_order_time","value":"20","customer_id":437,"location_id":907}]}},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Optional."}},"required":[]},"module":"location_business","safety":"read"},{"name":"create_location","description":"POST /api/locations. Why: Open a new branch (location) for the tenant — the dashboard Locations > Add form. It only inserts the locations row (name + warehouse flag), links every synced payment method to it, optionally links all master products / categories, and appends the new id to the CALLING USER's allow_locations. It does NOT create business info, VAT, currency, time zone, opening hours, weekday hours, menus, brands, tables or users for the branch — configure those afterwards (business-info / config tools, upsert_location_days_hours, update_location with sync_* flags, create_* tools with the new location_id). Counts against the tenant's max_locations subscription limit. Confirm the name with the user first. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_location\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/locations","why":"Open a new branch (location) for the tenant — the dashboard Locations > Add form. It only inserts the locations row (name + warehouse flag), links every synced payment method to it, optionally links all master products / categories, and appends the new id to the CALLING USER's allow_locations. It does NOT create business info, VAT, currency, time zone, opening hours, weekday hours, menus, brands, tables or users for the branch — configure those afterwards (business-info / config tools, upsert_location_days_hours, update_location with sync_* flags, create_* tools with the new location_id). Counts against the tenant's max_locations subscription limit. Confirm the name with the user first.","response":{"description":"HTTP 201. Order of work: name check → max_locations check → INSERT locations(name, customer_id, is_ware_house) → link every payment method with sync_locations = 1 (payment_methods_locations) → optional product / category links → UPDATE users.allow_locations of the calling user = its JWT allow_locations + \",<newId>\". Errors (JSON { message }): 400 \"Location with this name already exists\"; 400 \"Max locations reached\" (customers.max_locations ≤ current branch count — also when max_locations is NULL); 500 \"Database error occurred\" / \"Failed to create location\" / \"Internal server error\".","fields":{"message":"string. \"Location created successfully\".","data":"object. Echo of the request body plus customer_id and id (not a DB re-read).","data.id":"int. NEW location id — use it as loc / location_id from now on.","data.customer_id":"int. Tenant id.","data.name":"string. Echo of name.","data.isWareHouse":"string. Echo of isWareHouse (absent when not sent).","syncedProductsCount":"int. Products linked; 0 = not requested / nothing to link; -1 = product sync failed.","syncMessage":"string. Product-sync note (\"\" when not requested or done, e.g. \"No products found to sync\", \"Product sync failed\").","syncedCategoriesCount":"int. Categories linked; 0 = none; -1 = failed.","categoriesSyncMessage":"string. Category-sync note (\"\" when not requested or done).","syncedMaterialsCount":"int. Always 0 via this tool (materials sync not exposed).","materialsSyncMessage":"string. Always \"\" via this tool."},"master_rules":["MASTER tenants sell a product at a branch only through its products_locations row — without sync_products (or later update_location sync_products / assigning products to the branch) the new branch has an empty menu. Same for categories (categories_locations), brands, groups, menus, modifiers, deals, labels: link them with update_location sync_* flags or the create/update tools of those entities.","The new branch has no config rows: get_pos_configs returns [] and web-pos falls back to defaults (VAT from user extras, currency AED, time zone +04:00, business day from 05:00). Set business info / VAT / currency / time zone / opening_hour through the config tools before selling.","MCP allow_locations: the new id is appended to the USER's allow_locations, NOT to the API key's. A key restricted by allow_locations cannot read or configure the new branch (every later call with that loc/location_id is rejected) until the key's allow_locations is edited in the dashboard. Unrestricted keys can use it at once (after re-authentication, since the JWT carries the old list)."],"caveats":"Not transactional: a failure after the INSERT still leaves the branch created. The user's allow_locations is rebuilt from the JWT copy, so changes made to it since login are overwritten; if the user's allow_locations is NULL the request can fail after the insert with no response — check list_locations before retrying to avoid a duplicate name error. Materials sync (sync_materials) is a non-master feature and is not exposed.","example":{"message":"Location created successfully","data":{"name":"Marina Walk","isWareHouse":"0","sync_products":"1","sync_categories":"1","customer_id":437,"id":1132},"syncedProductsCount":152,"syncMessage":"","syncedCategoriesCount":18,"categoriesSyncMessage":"","syncedMaterialsCount":0,"materialsSyncMessage":""}},"parameters":{"type":"object","properties":{"name":{"type":"string","description":"Branch name."},"isWareHouse":{"type":"string","description":"Optional."},"sync_products":{"type":"string","description":"Optional."},"sync_categories":{"type":"string","description":"Optional."}},"required":["name"]},"module":"location_business","safety":"write"},{"name":"update_location","description":"PUT /api/locations/:id. Why: Rename a branch, toggle its warehouse flag, set its payment provider / N-Genius terminal / menu-checkout / invoice-service version, and optionally link ALL master items of a catalog entity to it (sync_* flags — the way to fill a new or incomplete branch on a MASTER tenant). It is a full overwrite of name, isWareHouse, enable_menu_checkout, payment_provider, ngenius_terminal_id and ngenius_merchant_id: call get_location first and send every one of those with its current value unless you mean to change or clear it. Business info, hours, VAT, currency are config rows, not this tool. Confirm with the user before any sync_* flag — it can reset per-branch overrides. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_location\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/locations/:id","why":"Rename a branch, toggle its warehouse flag, set its payment provider / N-Genius terminal / menu-checkout / invoice-service version, and optionally link ALL master items of a catalog entity to it (sync_* flags — the way to fill a new or incomplete branch on a MASTER tenant). It is a full overwrite of name, isWareHouse, enable_menu_checkout, payment_provider, ngenius_terminal_id and ngenius_merchant_id: call get_location first and send every one of those with its current value unless you mean to change or clear it. Business info, hours, VAT, currency are config rows, not this tool. Confirm with the user before any sync_* flag — it can reset per-branch overrides.","response":{"description":"HTTP 200. UPDATE locations SET name, is_ware_house, enable_menu_checkout, payment_provider, ngenius_terminal_id, ngenius_merchant_id, invoice_service_version = COALESCE(new, old) WHERE customer_id AND id; then each requested sync runs in order products, categories, brands, groups, menus, labels, deals, modifiers. For each sync the response adds synced<Type>Count and <type>SyncMessage. Errors (JSON { message }): 400 \"Location with this name already exists\"; 400 \"invoice_service_version must be 'v1' or 'v2'\"; 404 \"Location not found\"; 500 \"Failed to update location\" / \"Internal server error\".","fields":{"message":"string. \"Location updated successfully\".","data":"object. Echo of the request body plus customer_id and id (id is the path STRING). Not a DB re-read — call get_location to see stored values.","syncedProductsCount":"int. Only when sync_products sent. Rows linked; 0 = nothing to do (branch already has at least as many links as master items, or no items); -1 = failed.","productsSyncMessage":"string. Only when sync_products sent, e.g. \"Products synced successfully\", \"Nothing to sync - all products already available at this location\".","syncedCategoriesCount":"int. Only when sync_categories sent. Same meaning as syncedProductsCount.","categoriesSyncMessage":"string. Only when sync_categories sent, e.g. \"Successfully synced 18 Categories\".","syncedBrandsCount":"int. Only when sync_brands sent.","brandsSyncMessage":"string. Only when sync_brands sent.","syncedGroupsCount":"int. Only when sync_groups sent.","groupsSyncMessage":"string. Only when sync_groups sent.","syncedMenusCount":"int. Only when sync_menus sent.","menusSyncMessage":"string. Only when sync_menus sent.","syncedLabelsCount":"int. Only when sync_labels sent.","labelsSyncMessage":"string. Only when sync_labels sent.","syncedDealsCount":"int. Only when sync_deals sent.","dealsSyncMessage":"string. Only when sync_deals sent.","syncedModifiersCount":"int. Only when sync_modifiers sent.","modifiersSyncMessage":"string. Only when sync_modifiers sent."},"master_rules":["sync_* writes the MASTER link tables (<entity>_locations: products_locations, categories_locations, brands_locations, groups_locations, menu_locations, labels_locations, deals_locations, modifiers_locations): the branch then sees EVERY tenant item of that entity. To link only some items use that entity's own create/update tool (location_id lists) instead.","A sync is skipped when the branch already has ≥ as many links as the tenant has items; otherwise all of the branch's links for that entity are deleted and rebuilt — items deliberately removed from the branch come back, and products_locations overrides (branch price, is_available, is_sold_out) are reset to master values.","Not exposed on purpose: sync_addons (would move master addon products to this branch by setting products.location_id), sync_modifier_items (deletes the branch's modifier-item links, then the re-insert uses a wrong column name and fails), sync_channels (fails on the channels_locations schema), sync_materials (non-master feature).","The dashboard Locations form sends only name + isWareHouse, and the Payment Providers page sends the list_locations row (is_ware_house, not isWareHouse) — so dashboard edits can silently clear the other fields; this tool avoids that only if you send them."],"caveats":"Omitted overwrite fields become NULL, not \"unchanged\" (only invoice_service_version is kept). Sending an empty string stores \"\". Sync results are reported per entity; a -1 count means that entity failed while the row update still succeeded. MCP does not check :id against the key's allow_locations for this tool.","example":{"message":"Location updated successfully","data":{"name":"Marina Walk","isWareHouse":"0","enable_menu_checkout":0,"invoice_service_version":"v1","sync_menus":1,"customer_id":437,"id":"1132"},"syncedMenusCount":3,"menusSyncMessage":"Successfully synced 3 Menus"}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Location id (list_locations[].id)."},"name":{"type":"string","description":"Branch name — ALWAYS send it (current name if unchanged): the UPDATE writes it, so omitting it blanks the name."},"isWareHouse":{"type":"string","description":"\"1\" = warehouse, \"0\" = not."},"enable_menu_checkout":{"type":"number","description":"1 = QR/online menu checkout on, 0 = off."},"payment_provider":{"type":"string","description":"Online payment gateway id (e.g."},"ngenius_terminal_id":{"type":"string","description":"N-Genius terminal id (TID)."},"ngenius_merchant_id":{"type":"string","description":"N-Genius merchant id (MID)."},"invoice_service_version":{"type":"string","enum":["v1","v2"],"description":"Optional."},"sync_products":{"type":"string","description":"Optional."},"sync_categories":{"type":"string","description":"Optional."},"sync_brands":{"type":"string","description":"Optional."},"sync_groups":{"type":"string","description":"Optional."},"sync_menus":{"type":"string","description":"Optional."},"sync_labels":{"type":"string","description":"Optional."},"sync_deals":{"type":"string","description":"Optional."},"sync_modifiers":{"type":"string","description":"Optional."}},"required":["id","name"]},"module":"location_business","safety":"write"},{"name":"delete_location","description":"DELETE /api/locations/:id. Why: Permanently delete a branch that was never used. The server refuses (status false) when ANY row still points at the branch in config, customers, inventory, invoices, invoice_items, orders, order_items, materials, product_materials, products, categories, tables or users — so a branch that sold anything, has staff, tables, or even saved business info / hours config cannot be deleted. On success it hard-deletes the locations row and removes the id from the calling user's allow_locations. Confirm with the user first; there is no undo and no soft delete. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_location\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/locations/:id","why":"Permanently delete a branch that was never used. The server refuses (status false) when ANY row still points at the branch in config, customers, inventory, invoices, invoice_items, orders, order_items, materials, product_materials, products, categories, tables or users — so a branch that sold anything, has staff, tables, or even saved business info / hours config cannot be deleted. On success it hard-deletes the locations row and removes the id from the calling user's allow_locations. Confirm with the user first; there is no undo and no soft delete.","response":{"description":"Always HTTP 200 with a JSON body (sent without a JSON content-type): { status: true, message: \"Location deleted successfully.\" } or { status: false, message: \"You can't delete this location. It's being used.\" }. Side effects on success: DELETE FROM locations WHERE customer_id AND id; UPDATE users.allow_locations of the calling user = JWT list with \",<id>\" removed.","fields":{"status":"boolean. true = deleted (also reported when the id did not exist or was another tenant's — nothing was deleted then); false = blocked because the branch is in use.","message":"string. \"Location deleted successfully.\" or \"You can't delete this location. It's being used.\"."},"master_rules":["MASTER link rows are NOT checked and NOT removed: products_locations, categories_locations, brands_locations, groups_locations, menu_locations, modifiers_locations, deals_locations, labels_locations, channels_locations, payment_methods_locations and location_days_hours_config rows of the branch stay behind as orphans. They are harmless for reads that join locations but still show in counts of those tables.","On master tenants products / categories have location_id NULL, so they never block deletion; the blockers that matter are config rows (any saved business info / VAT / hours for the branch), invoices/orders, tables and users assigned to it."],"caveats":"To retire a branch that has history, do not delete it — rename it (update_location) and remove it from users' / keys' allow_locations. The allow_locations clean-up strips the first \",<id>\" substring (it can hit a longer id that starts with the same digits, and misses the id when it is first in the list). MCP does not check :id against the key's allow_locations for this tool. Verify with list_locations afterwards.","example":{"status":false,"message":"You can't delete this location. It's being used."}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Location id (list_locations[].id)."}},"required":["id"]},"module":"location_business","safety":"destructive"},{"name":"upsert_location_days_hours","description":"POST /api/location-days-hours-config/bulk-upsert. Why: Set a branch's weekly operating hours — exactly what the dashboard Operating Hours 'Save' does (it always sends all 7 days). Each config updates that weekday's row if it exists, else creates it. Days you leave out are untouched. This does NOT change the business day / opening_hour config used by the POS and reports (that is a config row). Confirm the schedule with the user first. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"upsert_location_days_hours\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/location-days-hours-config/bulk-upsert","why":"Set a branch's weekly operating hours — exactly what the dashboard Operating Hours 'Save' does (it always sends all 7 days). Each config updates that weekday's row if it exists, else creates it. Days you leave out are untouched. This does NOT change the business day / opening_hour config used by the POS and reports (that is a config row). Confirm the schedule with the user first.","response":{"description":"HTTP 200 { message, data, results } — data is the branch's full schedule re-read after the writes (same shape as get_location_days_hours). Validation (day missing / not a weekday) → HTTP 400 { message: \"Validation errors occurred\", errors: [\"Config 2: Invalid day value 'funday'\"], results } and nothing is written. Other errors: 400 \"Configs array is required and must not be empty\"; 400 \"Location ID is required\"; 403 \"Access denied to this location\"; 500 \"Failed to upsert location days hours configs\". A bad time format does not fail the request: it is counted in results.failed with a message in results.errors (new days are inserted in one batch, so one bad time fails all new days).","fields":{"message":"string. \"Bulk upsert completed. Created: N, Updated: N, Failed: N\".","data":"array. Every weekday row of the branch after the upsert, monday → sunday.","data[].id":"int. Row id.","data[].customer_id":"int. Tenant id.","data[].location_id":"int. Branch id.","data[].day":"string. Weekday, lowercase.","data[].open":"string or null. \"HH:mm\" opening time.","data[].close":"string or null. \"HH:mm\" closing time.","data[].is_off":"int. 1 = closed all day, 0 = open.","data[].created_at":"string. ISO timestamp of first save.","data[].updated_at":"string. ISO timestamp (not bumped on edits).","results.created":"int. New weekday rows inserted.","results.updated":"int. Existing weekday rows updated.","results.failed":"int. Days that could not be written.","results.errors":"string[]. One message per failure, e.g. \"Error updating configuration with ID 31: Invalid open time format. Use HH:MM or HH:MM:SS\"."},"master_rules":["Per branch; master tenants set each branch separately (no tenant-wide default).","Only the weekly schedule changes: get_business_hours, KDS 'today', get_today_sales and reports keep using the opening_hour / time_zone config rows."],"caveats":"Check results.failed — HTTP 200 does not mean every day was saved. Rows are matched by (branch, day), not by id.","example":{"message":"Bulk upsert completed. Created: 0, Updated: 2, Failed: 0","data":[{"id":29,"customer_id":437,"location_id":909,"day":"monday","open":"09:00","close":"23:00","is_off":0,"created_at":"2025-10-03T05:50:48.000Z","updated_at":"2025-10-03T05:50:48.000Z"},{"id":30,"customer_id":437,"location_id":909,"day":"tuesday","open":"09:00","close":"23:00","is_off":0,"created_at":"2025-10-03T05:50:48.000Z","updated_at":"2025-10-03T05:50:48.000Z"},{"id":31,"customer_id":437,"location_id":909,"day":"wednesday","open":"12:00","close":"00:00","is_off":0,"created_at":"2025-10-03T05:50:48.000Z","updated_at":"2025-10-03T05:50:48.000Z"},{"id":32,"customer_id":437,"location_id":909,"day":"thursday","open":"12:00","close":"00:00","is_off":0,"created_at":"2025-10-03T05:50:48.000Z","updated_at":"2025-10-03T05:50:48.000Z"},{"id":33,"customer_id":437,"location_id":909,"day":"friday","open":"12:00","close":"00:00","is_off":0,"created_at":"2025-10-03T05:50:48.000Z","updated_at":"2025-10-03T05:50:48.000Z"},{"id":34,"customer_id":437,"location_id":909,"day":"saturday","open":"12:00","close":"00:00","is_off":0,"created_at":"2025-10-03T05:50:48.000Z","updated_at":"2025-10-03T05:50:48.000Z"},{"id":35,"customer_id":437,"location_id":909,"day":"sunday","open":null,"close":null,"is_off":1,"created_at":"2025-10-03T05:50:48.000Z","updated_at":"2025-10-03T05:50:48.000Z"}],"results":{"created":0,"updated":2,"failed":0,"errors":[]}}},"parameters":{"type":"object","properties":{"location_id":{"type":"number","description":"ONE branch id (list_locations[].id)."},"configs":{"type":"array","items":{"type":"object","properties":{"day":{"type":"string","enum":["monday","tuesday","wednesday","thursday","friday","saturday","sunday"],"description":"Weekday, case-insensitive (stored lowercase)."},"open":{"type":"string","description":"Opening time \"HH:mm\" or \"HH:mm:ss\", 24 h, branch local time."},"close":{"type":"string","description":"Closing time \"HH:mm\" or \"HH:mm:ss\"."},"is_off":{"type":"string","description":"1 / true = closed all day (send it without open/close), 0 / false = open."}},"required":["day"],"description":""},"description":"1–7 day entries; send each weekday at most once (a new day listed twice creates two rows)."}},"required":["location_id","configs"]},"module":"location_business","safety":"write"},{"name":"list_selling_floors","description":"GET /api/v2/floors. Why: Floors (dining areas) of the cashier's session branch — what web-pos loads at boot for the floor tabs on the Dine screen and in the \"Select table\" dialog of a dine-in order. A floor only groups tables: tables point to it with floor_id (list_selling_tables), and the live occupancy per table comes from list_dine_orders. Session branch only (no params); for another branch use list_floors_by_location. Not paginated. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_selling_floors\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/floors","why":"Floors (dining areas) of the cashier's session branch — what web-pos loads at boot for the floor tabs on the Dine screen and in the \"Select table\" dialog of a dine-in order. A floor only groups tables: tables point to it with floor_id (list_selling_tables), and the live occupancy per table comes from list_dine_orders. Session branch only (no params); for another branch use list_floors_by_location. Not paginated.","response":{"description":"{ status: true, message: \"Record Found\", data: floors[] } for the JWT session branch; when the branch has no floor: { status: true, message: \"Any message\", data: [] }. Not sorted by the server (rows come back in id order in practice). On a database error the request fails without a JSON body.","fields":{"status":"boolean. Always true on success.","message":"string. \"Record Found\" (rows) or \"Any message\" (no rows). NOT USED.","data[]":"array. Floors of the session branch. [] = none (web-pos then shows no floor tab and no table).","data[].id":"int. Floor id. USE: floor tab key; a table belongs to this floor when table.floor_id = id (list_selling_tables / list_dine_orders rows carry floor_id).","data[].name":"string | null. Floor name. USE: tab label (shown capitalized) and the floor printed on the bill / kitchen ticket header.","data[].customer_id":"int. Tenant id. NOT USED.","data[].location_id":"int. Branch id (list_locations). Always the session branch here. NOT USED by web-pos.","data[].image":"string. Absolute URL of the floor background image (BACKEND_URL/uploads/…), or \"\" when none. NOT USED by web-pos.","data[].web_image":"string. Same image as a relative path \"api/uploads/…\", or \"\" when none (added by the server, not a DB column). NOT USED by web-pos.","data[].width":"int. Floor canvas width (default 0 = not set). NOT USED by web-pos.","data[].length":"int. Floor canvas length (default 0 = not set). NOT USED by web-pos.","data[].floor_design":"string | null. Legacy free-text design flag (live values \"\" and \"false\"). NOT USED by web-pos."},"master_rules":["Floor tabs (web-pos): one tab per floor in the returned order; the FIRST floor is selected by default. A table appears under a tab when table.floor_id = floor.id. Tables with floor_id null or pointing to a floor that is not in this list are never shown, so they cannot be picked for a dine-in order.","Occupancy is not on the floor or the table: call list_dine_orders (same session branch) and read dinerOrderDetail per table (see list_selling_tables rules).","Floor-plan positions, shapes and zones edited with web-pos \"Edit layout\" are saved only in that browser (localStorage, per floor id) — never on the server. Agents cannot read them; use table x / y as the only server-side position.","The floor name is printed on the bill and kitchen ticket header of a dine-in order (looked up from the order's table_id → table.floor_id → floor.name).","Branch: this is the JWT session branch (list_locations). To read another branch use list_floors_by_location with its location_id."],"example":{"status":true,"message":"Record Found","data":[{"id":606,"name":"Ground Floor","customer_id":437,"location_id":907,"image":"","width":0,"length":0,"floor_design":"","web_image":""},{"id":615,"name":"1st Floor","customer_id":437,"location_id":907,"image":"","width":0,"length":0,"floor_design":"false","web_image":""},{"id":619,"name":"Second Floor","customer_id":437,"location_id":907,"image":"","width":0,"length":0,"floor_design":"","web_image":""}]}},"parameters":{"type":"object","properties":{},"required":[]},"module":"dine","safety":"read"},{"name":"list_selling_tables","description":"GET /api/v2/tables. Why: Tables of the cashier's session branch — what web-pos loads at boot to resolve table names (tickets, order list, bill header) and each table's floor. The table id is what a dine-in order carries as table_id. Occupancy is NOT here: list_dine_orders returns the same tables with their open tickets (dinerOrderDetail) and is what the Dine screen actually draws; get_table_order gives the latest ticket of one table. Tables have no brand_id. Session branch only (no params); for another branch use list_tables_by_location. Not paginated. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_selling_tables\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/tables","why":"Tables of the cashier's session branch — what web-pos loads at boot to resolve table names (tickets, order list, bill header) and each table's floor. The table id is what a dine-in order carries as table_id. Occupancy is NOT here: list_dine_orders returns the same tables with their open tickets (dinerOrderDetail) and is what the Dine screen actually draws; get_table_order gives the latest ticket of one table. Tables have no brand_id. Session branch only (no params); for another branch use list_tables_by_location. Not paginated.","response":{"description":"A BARE JSON ARRAY of every table row of the JWT session branch (all columns of the tables table), not sorted (id order in practice), NOT filtered by is_active / unavailable / current_status. [] when the branch has no table. On a database error the body is empty.","fields":{"[]":"array. Tables of the session branch. [] = none.","[].id":"int. Table id. USE: this is what goes on a dine-in order as table_id (and web-pos also sends it as the order's top-level id). Match it with list_dine_orders[].id and get_table_order :table.","[].name":"string | null. Table name (e.g. \"Table 1\"). USE: label on the floor plan / card, on tickets and in the order list (\"Order#12 on Table 1\").","[].customer_id":"int. Tenant id. NOT USED.","[].location_id":"int. Branch id (list_locations). Always the session branch here. NOT USED.","[].floor_id":"int | null. Floor id (list_selling_floors). USE: the table is shown under the floor tab whose id matches; null or unknown floor → web-pos never shows the table.","[].people_size":"int. Seats. Default 0 — tables created from the dashboard keep 0. USE: floor-plan shape and chair count (0 is drawn as 2 seats: ≤2 round, ≤4 square, ≤6 long, else VIP), \"N Guests\" on the card, and the fallback dining_guests of a new dine-in order (0 when not set).","[].capacity":"int. Seats from the newer table-management screen (default 4). NOT USED by web-pos (it reads people_size).","[].x":"int. Floor-plan X position (default 50). USE: table position on the plan when the browser has no saved layout for that table.","[].y":"int. Floor-plan Y position (default 50). USE: as x.","[].width":"int. Drawn width (default 30). NOT USED by web-pos.","[].height":"int. Drawn height (default 30). NOT USED by web-pos.","[].rotation":"int. Rotation in degrees (default 0). NOT USED by web-pos.","[].is_rounded":"0 | 1. 1 = round table (default 0). NOT USED — web-pos derives the shape from people_size.","[].x_pos":"int | null. Legacy position (superseded by x). NOT USED.","[].y_pos":"int | null. Legacy position (superseded by y). NOT USED.","[].unavailable":"0 | 1. Legacy \"table unavailable\" flag. NOT USED — web-pos does not hide or lock such tables; do not treat it as occupancy.","[].is_active":"0 | 1. Table active flag (default 1). NOT USED — inactive tables are still returned and still shown.","[].current_status":"\"available\" | \"occupied\" | \"unavailable\". Manually set dashboard status (default \"available\"). NOT the live occupancy — derive that from list_dine_orders. NOT USED by web-pos.","[].attended_by":"string | null. Legacy waiter name. NOT USED by web-pos.","[].is_plate":"0 | 1. Legacy plate-number (drive-in) flag. NOT USED by web-pos.","[].plate_number":"string | null. Legacy plate number on the table row. NOT USED by web-pos.","[].guest_name":"string | null. Legacy guest name on the table row (the live guest name is on the order). NOT USED by web-pos.","[].pgo_restaurant_short_name":"string | null. PayGetOrder QR restaurant code. NOT USED by web-pos.","[].pgo_table_short_id":"string | null. PayGetOrder QR table code. NOT USED by web-pos.","[].qr_code_link":"string | null. Generated table QR link. NOT USED by web-pos.","[].table_number":"string | null. Optional display number (live: null). NOT USED by web-pos.","[].today_activity_percentage":"number. Dashboard activity metric (default 0). NOT USED by web-pos.","[].last_activity_at":"string | null. ISO timestamp of last dashboard activity. NOT USED by web-pos.","[].created_at":"string. ISO timestamp (UTC). NOT USED by web-pos.","[].updated_at":"string. ISO timestamp (UTC). NOT USED by web-pos."},"master_rules":["Dine-in = order type title containing \"dine\" or \"dining\" (list_selling_order_types). web-pos asks for a table when a dine-in order is SAVED (sent to kitchen / parked); saving without a table is refused (\"Please select a table for dine-in orders\"). Paying directly does not require a table. Non-dine-in orders carry table_id null.","Table status = open tickets, not table columns: call list_dine_orders and find the row with id = table id. dinerOrderDetail non-empty → OCCUPIED; null or [] → FREE. unavailable, is_active and current_status are ignored by web-pos. The \"reserved\", \"late\" and \"bill\" badges in the web-pos floor plan are never produced by the server today.","Picking a table for a NEW dine-in order: pick a FREE table. If the chosen table is occupied, web-pos offers \"add items to the existing ticket\" (reopen it: list_dine_orders / get_table_order, keep its uuid) or \"start a new ticket on the same table\" — several open tickets on one table are allowed.","What a dine-in order carries (order payload out of scope here): table_id = table id; web-pos also sends the table id as the order's top-level id; dining_guests = guests entered by the cashier, else the existing order's dining_guests, else table.people_size (0 when unset; always 0 for non-dine-in types); brand_id = the cashier's current brand (tables have no brand_id); round = 1 for a new ticket, and each later save of new items on the same ticket uses the next round (items keep the round they were saved in).","Transfer (web-pos): selected items (with quantities, after a PIN) move from one table's ticket to another table — onto an existing open ticket there or onto a new ticket with table_id = destination table id and dining_guests = destination people_size. There is no whole-table merge or table-split feature; \"split\" in web-pos is split payment.","Floor: table.floor_id → list_selling_floors[].id. Branch: location_id = session branch (list_locations). For another branch use list_tables_by_location."],"caveats":"people_size is 0 on tables created from the dashboard (the create screen does not save it); capacity holds the seat count there but web-pos does not read it.","example":[{"id":5935,"name":"Table A5","customer_id":437,"location_id":907,"x_pos":null,"y_pos":null,"people_size":0,"attended_by":null,"unavailable":1,"plate_number":null,"is_plate":1,"guest_name":null,"floor_id":606,"width":30,"height":30,"rotation":0,"is_rounded":0,"x":72,"y":49,"pgo_restaurant_short_name":null,"pgo_table_short_id":null,"is_active":1,"capacity":4,"current_status":"available","qr_code_link":null,"today_activity_percentage":0,"last_activity_at":null,"table_number":null,"created_at":"2026-02-24T11:13:55.000Z","updated_at":"2026-02-24T11:13:55.000Z"},{"id":6194,"name":"Table 1","customer_id":437,"location_id":907,"x_pos":null,"y_pos":null,"people_size":0,"attended_by":null,"unavailable":0,"plate_number":null,"is_plate":0,"guest_name":null,"floor_id":606,"width":30,"height":30,"rotation":0,"is_rounded":0,"x":42,"y":29,"pgo_restaurant_short_name":null,"pgo_table_short_id":null,"is_active":1,"capacity":4,"current_status":"available","qr_code_link":null,"today_activity_percentage":0,"last_activity_at":null,"table_number":null,"created_at":"2026-06-24T08:03:09.000Z","updated_at":"2026-06-24T08:03:09.000Z"}]},"parameters":{"type":"object","properties":{},"required":[]},"module":"dine","safety":"read"},{"name":"list_floors_by_location","description":"GET /api/floors-location/:location_id. Why: Floors of one branch (dashboard route; any branch of the tenant, not only the session branch). Tables link to a floor with floor_id (list_tables_by_floor / list_tables_by_location); live occupancy per table is in list_dine_orders. POS view of the session branch: list_selling_floors. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_floors_by_location\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/floors-location/:location_id","why":"Floors of one branch (dashboard route; any branch of the tenant, not only the session branch). Tables link to a floor with floor_id (list_tables_by_floor / list_tables_by_location); live occupancy per table is in list_dine_orders. POS view of the session branch: list_selling_floors.","response":{"description":"A BARE JSON ARRAY of floors of that branch (scoped to your tenant), not sorted (id order in practice). [] when none. On a database error the request fails without a JSON body.","fields":{"[]":"array. Floors. [] = none.","[].id":"int. Floor id. Tables link to it through tables.floor_id (list_tables_by_floor).","[].name":"string | null. Floor name. Display name.","[].customer_id":"int. Tenant id. NOT USED.","[].location_id":"int. Branch id (list_locations). Branch the floor belongs to.","[].image":"string. Absolute URL of the floor background image (BACKEND_URL/uploads/…), or \"\" when none.","[].web_image":"string. Same image as a relative path \"api/uploads/…\", or \"\" when none (added by the server, not a DB column).","[].width":"int. Floor canvas width (default 0 = not set).","[].length":"int. Floor canvas length (default 0 = not set).","[].floor_design":"string | null. Legacy free-text design flag (live values \"\" and \"false\"). No known meaning."},"example":[{"id":606,"name":"Ground Floor","customer_id":437,"location_id":907,"image":"","width":0,"length":0,"floor_design":"","web_image":""},{"id":615,"name":"1st Floor","customer_id":437,"location_id":907,"image":"","width":0,"length":0,"floor_design":"false","web_image":""},{"id":619,"name":"Second Floor","customer_id":437,"location_id":907,"image":"","width":0,"length":0,"floor_design":"","web_image":""}]},"parameters":{"type":"object","properties":{"location_id":{"type":"number","description":"Required."}},"required":["location_id"]},"module":"dine","safety":"read"},{"name":"get_floor","description":"GET /api/floors/:id. Why: One floor by id (dashboard route). Scoped to your tenant; any branch. Its tables: list_tables_by_floor with floor_id = id. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_floor\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/floors/:id","why":"One floor by id (dashboard route). Scoped to your tenant; any branch. Its tables: list_tables_by_floor with floor_id = id.","response":{"description":"{ status: true, message: \"Record Found\", data: [floor] } — a ONE-element array. Unknown id or a floor of another tenant: { status: true, message: \"No data found\", data: [] }. On a database error the request fails without a JSON body.","fields":{"status":"boolean. true (also when not found).","message":"string. \"Record Found\" or \"No data found\".","data[]":"array. The floor, or [] when not found.","data[].id":"int. Floor id. Tables link to it through tables.floor_id (list_tables_by_floor).","data[].name":"string | null. Floor name. Display name.","data[].customer_id":"int. Tenant id. NOT USED.","data[].location_id":"int. Branch id (list_locations). Branch the floor belongs to.","data[].image":"string. Absolute URL of the floor background image (BACKEND_URL/uploads/…), or \"\" when none.","data[].web_image":"string. Same image as a relative path \"api/uploads/…\", or \"\" when none (added by the server, not a DB column).","data[].width":"int. Floor canvas width (default 0 = not set).","data[].length":"int. Floor canvas length (default 0 = not set).","data[].floor_design":"string | null. Legacy free-text design flag (live values \"\" and \"false\"). No known meaning."},"example":{"status":true,"message":"Record Found","data":[{"id":606,"name":"Ground Floor","customer_id":437,"location_id":907,"image":"","width":0,"length":0,"floor_design":"","web_image":""}]}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Required."}},"required":["id"]},"module":"dine","safety":"read"},{"name":"list_tables_by_location","description":"GET /api/tables-location/:location_id. Why: Tables of one branch (dashboard route; any branch of the tenant). Same rows as list_selling_tables but for a chosen branch. Each table's floor is floor_id (get_floor); its open tickets are in list_dine_orders (session branch only) / get_table_order. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_tables_by_location\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/tables-location/:location_id","why":"Tables of one branch (dashboard route; any branch of the tenant). Same rows as list_selling_tables but for a chosen branch. Each table's floor is floor_id (get_floor); its open tickets are in list_dine_orders (session branch only) / get_table_order.","response":{"description":"A BARE JSON ARRAY of every table row of that branch (scoped to your tenant), not sorted (id order in practice), not filtered by is_active / unavailable. [] when none. On a database error the body is empty.","fields":{"[]":"array. Tables. [] = none.","[].id":"int. Table id. Use as table_id on orders, list_dine_orders table_id filter and get_table_order :table.","[].name":"string | null. Table name (e.g. \"Table 1\"). Display name.","[].customer_id":"int. Tenant id. NOT USED.","[].location_id":"int. Branch id (list_locations).","[].floor_id":"int | null. Floor id (list_floors_by_location / get_floor).","[].people_size":"int. Seats. Default 0 — tables created from the dashboard keep 0.","[].capacity":"int. Seats from the newer table-management screen (default 4).","[].x":"int. Floor-plan X position (default 50).","[].y":"int. Floor-plan Y position (default 50).","[].width":"int. Drawn width (default 30).","[].height":"int. Drawn height (default 30).","[].rotation":"int. Rotation in degrees (default 0).","[].is_rounded":"0 | 1. 1 = round table (default 0).","[].x_pos":"int | null. Legacy position (superseded by x).","[].y_pos":"int | null. Legacy position (superseded by y).","[].unavailable":"0 | 1. Legacy \"table unavailable\" flag.","[].is_active":"0 | 1. Table active flag (default 1).","[].current_status":"\"available\" | \"occupied\" | \"unavailable\". Manually set dashboard status (default \"available\"). NOT the live occupancy — derive that from list_dine_orders.","[].attended_by":"string | null. Legacy waiter name.","[].is_plate":"0 | 1. Legacy plate-number (drive-in) flag.","[].plate_number":"string | null. Legacy plate number on the table row.","[].guest_name":"string | null. Legacy guest name on the table row (the live guest name is on the order).","[].pgo_restaurant_short_name":"string | null. PayGetOrder QR restaurant code.","[].pgo_table_short_id":"string | null. PayGetOrder QR table code.","[].qr_code_link":"string | null. Generated table QR link.","[].table_number":"string | null. Optional display number (live: null).","[].today_activity_percentage":"number. Dashboard activity metric (default 0).","[].last_activity_at":"string | null. ISO timestamp of last dashboard activity.","[].created_at":"string. ISO timestamp (UTC).","[].updated_at":"string. ISO timestamp (UTC)."},"example":[{"id":5935,"name":"Table A5","customer_id":437,"location_id":907,"x_pos":null,"y_pos":null,"people_size":0,"attended_by":null,"unavailable":1,"plate_number":null,"is_plate":1,"guest_name":null,"floor_id":606,"width":30,"height":30,"rotation":0,"is_rounded":0,"x":72,"y":49,"pgo_restaurant_short_name":null,"pgo_table_short_id":null,"is_active":1,"capacity":4,"current_status":"available","qr_code_link":null,"today_activity_percentage":0,"last_activity_at":null,"table_number":null,"created_at":"2026-02-24T11:13:55.000Z","updated_at":"2026-02-24T11:13:55.000Z"},{"id":6194,"name":"Table 1","customer_id":437,"location_id":907,"x_pos":null,"y_pos":null,"people_size":0,"attended_by":null,"unavailable":0,"plate_number":null,"is_plate":0,"guest_name":null,"floor_id":606,"width":30,"height":30,"rotation":0,"is_rounded":0,"x":42,"y":29,"pgo_restaurant_short_name":null,"pgo_table_short_id":null,"is_active":1,"capacity":4,"current_status":"available","qr_code_link":null,"today_activity_percentage":0,"last_activity_at":null,"table_number":null,"created_at":"2026-06-24T08:03:09.000Z","updated_at":"2026-06-24T08:03:09.000Z"}]},"parameters":{"type":"object","properties":{"location_id":{"type":"number","description":"Required."}},"required":["location_id"]},"module":"dine","safety":"read"},{"name":"list_tables_by_floor","description":"GET /api/tables-floor/:floor_id. Why: Tables on one floor (dashboard route). Scoped to your tenant but NOT to a branch: floor_id 0 returns every table of the tenant across all branches, and the API key allow_locations is not applied to this tool — filter on location_id yourself. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_tables_by_floor\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/tables-floor/:floor_id","why":"Tables on one floor (dashboard route). Scoped to your tenant but NOT to a branch: floor_id 0 returns every table of the tenant across all branches, and the API key allow_locations is not applied to this tool — filter on location_id yourself.","response":{"description":"A BARE JSON ARRAY of table rows whose floor_id matches (scoped to your tenant), not sorted. [] when none. On a database error the body is empty.","fields":{"[]":"array. Tables. [] = none.","[].id":"int. Table id. Use as table_id on orders, list_dine_orders table_id filter and get_table_order :table.","[].name":"string | null. Table name (e.g. \"Table 1\"). Display name.","[].customer_id":"int. Tenant id. NOT USED.","[].location_id":"int. Branch id (list_locations).","[].floor_id":"int | null. Floor id (list_floors_by_location / get_floor).","[].people_size":"int. Seats. Default 0 — tables created from the dashboard keep 0.","[].capacity":"int. Seats from the newer table-management screen (default 4).","[].x":"int. Floor-plan X position (default 50).","[].y":"int. Floor-plan Y position (default 50).","[].width":"int. Drawn width (default 30).","[].height":"int. Drawn height (default 30).","[].rotation":"int. Rotation in degrees (default 0).","[].is_rounded":"0 | 1. 1 = round table (default 0).","[].x_pos":"int | null. Legacy position (superseded by x).","[].y_pos":"int | null. Legacy position (superseded by y).","[].unavailable":"0 | 1. Legacy \"table unavailable\" flag.","[].is_active":"0 | 1. Table active flag (default 1).","[].current_status":"\"available\" | \"occupied\" | \"unavailable\". Manually set dashboard status (default \"available\"). NOT the live occupancy — derive that from list_dine_orders.","[].attended_by":"string | null. Legacy waiter name.","[].is_plate":"0 | 1. Legacy plate-number (drive-in) flag.","[].plate_number":"string | null. Legacy plate number on the table row.","[].guest_name":"string | null. Legacy guest name on the table row (the live guest name is on the order).","[].pgo_restaurant_short_name":"string | null. PayGetOrder QR restaurant code.","[].pgo_table_short_id":"string | null. PayGetOrder QR table code.","[].qr_code_link":"string | null. Generated table QR link.","[].table_number":"string | null. Optional display number (live: null).","[].today_activity_percentage":"number. Dashboard activity metric (default 0).","[].last_activity_at":"string | null. ISO timestamp of last dashboard activity.","[].created_at":"string. ISO timestamp (UTC).","[].updated_at":"string. ISO timestamp (UTC)."},"example":[{"id":5935,"name":"Table A5","customer_id":437,"location_id":907,"x_pos":null,"y_pos":null,"people_size":0,"attended_by":null,"unavailable":1,"plate_number":null,"is_plate":1,"guest_name":null,"floor_id":606,"width":30,"height":30,"rotation":0,"is_rounded":0,"x":72,"y":49,"pgo_restaurant_short_name":null,"pgo_table_short_id":null,"is_active":1,"capacity":4,"current_status":"available","qr_code_link":null,"today_activity_percentage":0,"last_activity_at":null,"table_number":null,"created_at":"2026-02-24T11:13:55.000Z","updated_at":"2026-02-24T11:13:55.000Z"}]},"parameters":{"type":"object","properties":{"floor_id":{"type":"number","description":"Required."}},"required":["floor_id"]},"module":"dine","safety":"read"},{"name":"get_table","description":"GET /api/tables/:id. Why: BROKEN on the server — do not rely on it: the handler never binds the id, so the SQL fails and the response is HTTP 200 with an EMPTY body for every id. To read one table use list_selling_tables (session branch) or list_tables_by_location and match id. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_table\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/tables/:id","why":"BROKEN on the server — do not rely on it: the handler never binds the id, so the SQL fails and the response is HTTP 200 with an EMPTY body for every id. To read one table use list_selling_tables (session branch) or list_tables_by_location and match id.","response":{"description":"Intended: a one-element array with the table row (tenant-scoped). Actual today: HTTP 200 with an empty body (no JSON) for every id, because the `?` placeholder in the SQL is sent without a value.","example":"<empty body: HTTP 200, no JSON>"},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Required."}},"required":["id"]},"module":"dine","safety":"read"},{"name":"list_dine_orders","description":"GET /api/v2/dine-orders-v3. Why: The live dine board of the JWT session branch — exactly what web-pos draws on the Dine screen: EVERY table of the branch (id = table id, floor_id, name, people_size, x, y …) with its open/done tickets in dinerOrderDetail. A table is OCCUPIED when dinerOrderDetail has at least one ticket, FREE when it is null or []. Group rows by floor_id (list_selling_floors). Optional table_id filter (list_selling_tables). The location cannot be changed via query. For one table's latest ticket use get_table_order; for a ticket's lines use get_invoice_items with its uuid. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_dine_orders\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/dine-orders-v3","why":"The live dine board of the JWT session branch — exactly what web-pos draws on the Dine screen: EVERY table of the branch (id = table id, floor_id, name, people_size, x, y …) with its open/done tickets in dinerOrderDetail. A table is OCCUPIED when dinerOrderDetail has at least one ticket, FREE when it is null or []. Group rows by floor_id (list_selling_floors). Optional table_id filter (list_selling_tables). The location cannot be changed via query. For one table's latest ticket use get_table_order; for a ticket's lines use get_invoice_items with its uuid.","response":{"description":"A bare array with one row per table of the session branch (not only occupied ones): table columns + uuid (string[] of the tickets' uuids) + dinerOrderDetail (all open/done tickets of that table at the branch, newest first, each with order_items and floor_id; null when the table has no open/done ticket; [] when its tickets have no item lines). Every dinerOrderDetail[] ticket has the same fields and meanings as list_orders_history data.orders[] (its lines are in order_items[] = items[]); table columns match list_selling_tables.","example":[{"floor_id":606,"id":6194,"name":"Table 1","customer_id":437,"location_id":907,"x":42,"y":29,"is_rounded":0,"rotation":0,"height":30,"width":30,"people_size":0,"attended_by":null,"unavailable":0,"is_plate":0,"plate_number":null,"uuid":["b7b056e0-3c5f-437d-9258-968b6cc34a6e"],"dinerOrderDetail":[{"id":23,"customer_invoice_id":23,"uuid":"b7b056e0-3c5f-437d-9258-968b6cc34a6e","status":"open","order_no":1,"table_id":6194,"floor_id":606,"order_type":"Dine In","is_paid":null,"payable":31,"payment_method":null,"created_at":1787738588,"order_items":[{"id":25504110,"identifier":"5f1c0c66-2efb-4335-ab40-99ce6ceaa433","name":"Flat White","name_localize":null,"actual_price":11,"price":11,"qty":1,"subtotal":11,"line_total":null,"line_vat":null,"line_total_without_vat":null,"invoice_uuid":"b7b056e0-3c5f-437d-9258-968b6cc34a6e","product_id":257506,"extras":null,"vat":0,"is_updated":0,"updated_qty":0,"is_addon":0,"is_refunded":0,"refund_quantity":0,"refund_reason":null,"note":null,"addon_linked_with":"5f1c0c66-2efb-4335-ab40-99ce6ceaa433","discount_pct":0,"discount_amount":0,"modifier":[],"transferred_to":0,"transferred_to_uuid":null,"transferred_to_uuid_list":["3ccd598a-f022-4939-931f-eedd132e79b6","f2ab957f-2cba-4b48-a534-8681b8934a1a","0ea2c766-fd6d-418d-9e04-70aa6a60fa1a"],"transferred_quantities":[2,4,1.5],"transfer_recipients":[12,43,60],"created_at":1787738588,"updated_at":1787738588,"is_prepared":0,"preparation_time":null,"is_weightage_item":0,"unit_id":0,"type":"Espresso-Based Drinks","label_id":50,"item_status":"active","linked_uuid":null,"original_linked_uuid":null,"disposition_type":null,"image":null,"is_combo":0,"is_deleted":0,"deleted_at":null,"deleted_qty":0,"coupon_amount":0,"coupon_id":0,"coupon_code":"","updated_by_user_ids":[],"re":null,"ri":0,"is_wastage":0,"discount_id":null,"discount_name":null,"round":null,"combo_items":null}]}]},{"floor_id":606,"id":6195,"name":"Table 2","customer_id":437,"location_id":907,"x":29,"y":46,"is_rounded":0,"rotation":0,"height":30,"width":30,"people_size":0,"attended_by":null,"unavailable":0,"is_plate":0,"plate_number":null,"uuid":[],"dinerOrderDetail":null}],"master_rules":["Occupancy: dinerOrderDetail non-empty → occupied; null or [] → free. One table can hold several open tickets (web-pos then asks which ticket to open).","Floor grouping: row.floor_id = list_selling_floors[].id. Row id = table id = the table_id carried by dine-in orders (order types whose title contains \"dine\"/\"dining\", list_selling_order_types).","brand_id filters only the tickets, not the tables (a table with tickets of another brand shows as free). web-pos does not send brand_id."],"fields":{"[]":"array. One row per table of the session branch (occupied AND free). [] = branch has no tables.","[].uuid":"string[]. uuids of the table's open/done tickets (same order as dinerOrderDetail). [] when the table is free. Use a uuid with cancel_invoice / get_invoice_activities / list_orders_history.","[].dinerOrderDetail":"array | null. The table's open/done tickets, newest first. null or [] = the table is FREE; one or more rows = OCCUPIED (web-pos table status). Each row has the same fields as list_orders_history data.orders[] (listed below), with its lines in order_items[].","[].dinerOrderDetail[].floor_id":"int. Floor of the ticket's table (list_selling_floors data[].id).","[].dinerOrderDetail[].order_items":"array. The ticket's item lines — same fields as list_orders_history data.orders[].items[] (listed below).","[].floor_id":"int | null. Floor id (list_selling_floors). USE: the table is shown under the floor tab whose id matches; null or unknown floor → web-pos never shows the table.","[].id":"int. Table id. USE: this is what goes on a dine-in order as table_id (and web-pos also sends it as the order's top-level id). Match it with list_dine_orders[].id and get_table_order :table.","[].name":"string | null. Table name (e.g. \"Table 1\"). USE: label on the floor plan / card, on tickets and in the order list (\"Order#12 on Table 1\").","[].customer_id":"int. Tenant id. NOT USED.","[].location_id":"int. Branch id (list_locations). Always the session branch here. NOT USED.","[].x":"int. Floor-plan X position (default 50). USE: table position on the plan when the browser has no saved layout for that table.","[].y":"int. Floor-plan Y position (default 50). USE: as x.","[].is_rounded":"0 | 1. 1 = round table (default 0). NOT USED — web-pos derives the shape from people_size.","[].rotation":"int. Rotation in degrees (default 0). NOT USED by web-pos.","[].height":"int. Drawn height (default 30). NOT USED by web-pos.","[].width":"int. Drawn width (default 30). NOT USED by web-pos.","[].people_size":"int. Seats. Default 0 — tables created from the dashboard keep 0. USE: floor-plan shape and chair count (0 is drawn as 2 seats: ≤2 round, ≤4 square, ≤6 long, else VIP), \"N Guests\" on the card, and the fallback dining_guests of a new dine-in order (0 when not set).","[].attended_by":"string | null. Legacy waiter name. NOT USED by web-pos.","[].unavailable":"0 | 1. Legacy \"table unavailable\" flag. NOT USED — web-pos does not hide or lock such tables; do not treat it as occupancy.","[].is_plate":"0 | 1. Legacy plate-number (drive-in) flag. NOT USED by web-pos.","[].plate_number":"string | null. Legacy plate number on the table row. NOT USED by web-pos.","[].dinerOrderDetail[].token_number":"int | null. Queue token of kiosk/aggregator orders. null in POS orders. NOT USED by web-pos history.","[].dinerOrderDetail[].is_wastage":"0 | 1. 1 = the order was cancelled AS WASTAGE (stock written off, not returned).","[].dinerOrderDetail[].is_accepted":"0 | 1 | null. Online/aggregator order accepted by the branch. NOT USED by web-pos history.","[].dinerOrderDetail[].external_order_ready":"0 | 1. Aggregator order marked ready. NOT USED by web-pos history.","[].dinerOrderDetail[].is_discount_on_total":"0 | 1 | null. NOT USED. How the order discount was entered.","[].dinerOrderDetail[].id":"string. The LAST 12 CHARACTERS of uuid. NOT a database id and NOT the order number. web-pos uses it only as the list key. Never send it to another tool — send uuid.","[].dinerOrderDetail[].customer_invoice_id":"int. THE INVOICE NUMBER (per tenant, grows by 1). Search it with the invoice_no param. items[].transferred_to points to this number.","[].dinerOrderDetail[].brand_id":"int | null. Brand of the order (list_selling_brands). Filter with brand_id. web-pos reuses it when an open order is reopened (Pay Now).","[].dinerOrderDetail[].items_number":"int. Number of lines when the order was saved (can differ from items.length).","[].dinerOrderDetail[].aggregator_order_id":"string | null. Order id at the delivery aggregator. NOT USED by web-pos history.","[].dinerOrderDetail[].subtotal":"number. Total of the lines before the order discount. Shown as Subtotal.","[].dinerOrderDetail[].vat":"number. Tax amount (tax is out of scope). Shown.","[].dinerOrderDetail[].payable":"number. GRAND TOTAL the customer owes. Shown as Total. Refunds do NOT lower it.","[].dinerOrderDetail[].discountamount":"number. Order-level discount amount. Shown when > 0.","[].dinerOrderDetail[].items_discount_amount":"number. Sum of the line discounts, recomputed from the lines by the server.","[].dinerOrderDetail[].is_ticket_available":"0 | 1. NOT USED.","[].dinerOrderDetail[].is_wallet_used":"0 | 1. 1 = paid (partly) from the customer's wallet.","[].dinerOrderDetail[].discountpct":"number. Order-level discount %. web-pos restores it when the order is reopened (Pay Now).","[].dinerOrderDetail[].paid":"number. Amount tendered.","[].dinerOrderDetail[].change":"number. Change handed back. Shown when > 0.","[].dinerOrderDetail[].note":"string | null. Order note. Shown. When an order is cancelled the server rewrites it to \"<reason> **canceled by: <username>\".","[].dinerOrderDetail[].uuid":"string. THE ORDER KEY. Pass it to get_invoice_activities, get_invoice, get_invoice_items, cancel_invoice, refund_invoice and update_invoice_payment_methods.","[].dinerOrderDetail[].deliverect_id":"string | null. NOT USED. Deliverect order id.","[].dinerOrderDetail[].created_at":"int. Unix seconds (UTC) when the order was created. Shown as date + time.","[].dinerOrderDetail[].updated_at":"int. Unix seconds of the last change.","[].dinerOrderDetail[].customer_id":"int. NOT USED. Tenant id.","[].dinerOrderDetail[].location_id":"int. Branch id of the order.","[].dinerOrderDetail[].row_discount":"0 | 1. 1 = line discounts also cover the line's modifiers/extras; 0 = product price only.","[].dinerOrderDetail[].info_name":"string | null. Name sent by the POS with the order. NOT shown by web-pos history.","[].dinerOrderDetail[].info_address":"string | null. Address sent with the order (delivery). NOT shown by web-pos history.","[].dinerOrderDetail[].info_phone":"string | null. Phone sent with the order. Shown as Phone.","[].dinerOrderDetail[].info_email":"string | null. E-mail sent with the order. Shown as Email.","[].dinerOrderDetail[].status":"string. Order state: \"open\" (saved, not paid), \"completed\" (paid and closed), \"cancelled\" (voided by cancel_invoice), \"canceled\" (older cancel path, also void). \"pending\"/\"done\" can appear on online/dine tickets. A refund does NOT change it. See the rules.","[].dinerOrderDetail[].by":"string | null. Source label of aggregator orders. NOT USED.","[].dinerOrderDetail[].external_order_id":"string | null. Same as aggregator_order_id (\"\" becomes null). NOT USED by web-pos history.","[].dinerOrderDetail[].burn_points":"number. Loyalty points redeemed. Shown when > 0.","[].dinerOrderDetail[].earn_points":"number. Loyalty points earned. Shown when > 0.","[].dinerOrderDetail[].burn_points_amount":"number. Money value of the redeemed points. Shown with burn_points.","[].dinerOrderDetail[].available_points":"number. NOT USED. Customer's point balance saved with the order.","[].dinerOrderDetail[].available_points_amount":"number. NOT USED. Money value of that balance.","[].dinerOrderDetail[].charges_fee":"number | null. Service charge amount. Shown.","[].dinerOrderDetail[].delivery_charges":"number | null. Delivery fee.","[].dinerOrderDetail[].phone_number":"string. Customer phone on the order. Search with phone_number.","[].dinerOrderDetail[].loyalty_mobile_number":"string. NOT USED. Loyalty member mobile.","[].dinerOrderDetail[].loyalty_mobile_code":"string. NOT USED. Dial code of that mobile.","[].dinerOrderDetail[].crm_delivery_area":"string | null. NOT USED. Delivery area name.","[].dinerOrderDetail[].user":"string. Login (e-mail) of the POS user whose session saved the order.","[].dinerOrderDetail[].table_id":"int | null. Table of a dine-in order (list_selling_tables / list_dine_orders row id). 0 or null = no table. web-pos shows the table name in the list.","[].dinerOrderDetail[].order_id":"int | null. External/legacy order id (\"\" becomes null). NOT USED.","[].dinerOrderDetail[].created_by_user_id":"int | null. POS user who created the order (list_pos_users). Shown as Created By.","[].dinerOrderDetail[].close_by_user_id":"int | null. POS user who paid/closed or cancelled it (list_pos_users). Shown as Closed By.","[].dinerOrderDetail[].modified_by_user_ids":"int[]. POS users who edited the order. NOT USED by web-pos history.","[].dinerOrderDetail[].payment_method":"array | null. One { \"<method name>\": amount } per payment row, oldest first. Keys are list_selling_payment_methods names AS SAVED (case varies: \"cash\", \"Cash\", \"card\"). A negative amount is a refund row. null = no payment rows (open/unpaid orders). Old rows can show { \"0\": 0 }. Shown as badges.","[].dinerOrderDetail[].invoice_id":"int | null. NOT USED. Legacy invoice id.","[].dinerOrderDetail[].done_at":"int | null. Unix seconds when the kitchen bumped the order (KDS done). null = not bumped yet.","[].dinerOrderDetail[].period":"string | null. NOT USED.","[].dinerOrderDetail[].is_kds_printed":"0 | 1. Kitchen ticket printed. NOT USED by web-pos history.","[].dinerOrderDetail[].is_invoice_printed":"0 | 1. Receipt printed. NOT USED by web-pos history.","[].dinerOrderDetail[].plate_number":"string | null. Car plate (drive-through). Search with plate_number.","[].dinerOrderDetail[].guest_name":"string | null. Customer name typed on the order. Shown as Name. Search with guest_name.","[].dinerOrderDetail[].order_no":"int. SHORT ORDER NUMBER shown in the list (\"order_no\", falls back to oid). The counter restarts, so it is NOT unique — never use it as a key and it is not what invoice_no searches.","[].dinerOrderDetail[].is_paid":"1 | 0 | null. 1 = paid. null = NOT paid (the column holds 0/NULL for unpaid orders); 0 only on old rows. Treat anything but 1 as unpaid.","[].dinerOrderDetail[].crm_user_id":"int. Customer id (list_pos_crm_users). 0 = no customer. Shown as CRM User. Filter with crm_user_id.","[].dinerOrderDetail[].crm_user_name":"string. Customer name (\"\" when none). Search with crm_user_name.","[].dinerOrderDetail[].order_type":"string | null. Order type TITLE as saved (e.g. \"Dine In\", \"takeaway\"). Match it case-insensitively to list_selling_order_types title. null on old orders. The server CANNOT filter by it — filter this field yourself.","[].dinerOrderDetail[].channel_id":"int. Sales channel id (list_selling_channels). web-pos saves 0.","[].dinerOrderDetail[].tip_amount":"number. Tip on the order.","[].dinerOrderDetail[].is_vat_on_discount_exclude":"0 | 1. Tax setting (out of scope). NOT USED.","[].dinerOrderDetail[].dining_guests":"int | null. Number of guests (dine-in).","[].dinerOrderDetail[].channel_name":"string. Sales channel name (\"\" when none).","[].dinerOrderDetail[].discount_reason":"string | null. Reason typed for the order discount.","[].dinerOrderDetail[].paid_at":"int | null. Unix seconds of payment. null on many paid orders — use is_paid.","[].dinerOrderDetail[].coupon_amount":"number. Coupon discount on the order.","[].dinerOrderDetail[].coupon_id":"int. Coupon used (list_selling_coupons id). 0 = none.","[].dinerOrderDetail[].coupon_code":"string | null. Code of that coupon.","[].dinerOrderDetail[].printed_invoice_receipts":"int. NOT USED. Receipt print count.","[].dinerOrderDetail[].printed_kds_receipts":"int. NOT USED. Kitchen print count.","[].dinerOrderDetail[].app_version":"string | null. NOT USED. Version of the app that created the order.","[].dinerOrderDetail[].app_os_version":"string | null. NOT USED. OS version of that device.","[].dinerOrderDetail[].build_number":"string | null. NOT USED. Build of that app.","[].dinerOrderDetail[].os_type":"string | null. NOT USED. OS of that device.","[].dinerOrderDetail[].round":"int | null. Dine-in round (course) counter.","[].dinerOrderDetail[].rider_pickup_at":"int | null. Unix seconds the delivery rider picked up. NOT USED by web-pos history.","[].dinerOrderDetail[].rider_arrived_at":"int | null. Unix seconds the rider arrived. NOT USED by web-pos history.","[].dinerOrderDetail[].order_type_icon_key":"string | null. Iconify key of the order type icon.","[].dinerOrderDetail[].payment_methods":"array | null. Same rows as payment_method with tips: [{ \"<method name>\": { amount, tip_amount } }].","[].dinerOrderDetail[].payments":"array | null. Same rows, flat and easiest to read: [{ payment_method, amount, tip_amount }]. null = no payment.","[].dinerOrderDetail[].notes":"string[]. Order notes list. [] = none.","[].dinerOrderDetail[].order_type_icon":"string | null. PNG URL of the order type icon.","[].dinerOrderDetail[].order_items[].id":"int. Line id (invoice_items.id).","[].dinerOrderDetail[].order_items[].identifier":"string. Line uuid. Add-on rows point to it with addon_linked_with.","[].dinerOrderDetail[].order_items[].name":"string. Product name as sold (web-pos hides the text after \" - \").","[].dinerOrderDetail[].order_items[].name_localize":"string | null. Localized name.","[].dinerOrderDetail[].order_items[].actual_price":"number. Unit price of the product alone (VAT included). web-pos shows qty × actual_price.","[].dinerOrderDetail[].order_items[].price":"number. Unit price as saved by the POS. web-pos shows actual_price instead.","[].dinerOrderDetail[].order_items[].qty":"number. Quantity (weight for weighted products). A partial refund lowers it.","[].dinerOrderDetail[].order_items[].subtotal":"number. Line total as saved.","[].dinerOrderDetail[].order_items[].line_total":"number | null. Tax-era total, null in most data. NOT USED.","[].dinerOrderDetail[].order_items[].line_vat":"number | null. Tax (out of scope). NOT USED.","[].dinerOrderDetail[].order_items[].line_total_without_vat":"number | null. NOT USED.","[].dinerOrderDetail[].order_items[].invoice_uuid":"string. uuid of the order.","[].dinerOrderDetail[].order_items[].product_id":"int. Product (list_selling_products; add-on rows → list_selling_addons).","[].dinerOrderDetail[].order_items[].extras":"array | object | null. Priced extras [{ name, actual_price, price, quantity, discount_pct }]. Old non-master rows keep a { materialId: qty } map. null = none.","[].dinerOrderDetail[].order_items[].vat":"number. Tax (out of scope).","[].dinerOrderDetail[].order_items[].is_updated":"0 | 1. Quantity changed after the first save.","[].dinerOrderDetail[].order_items[].updated_qty":"int. The changed quantity.","[].dinerOrderDetail[].order_items[].is_addon":"0 | 1. 1 = ADD-ON ROW: show it under the main line whose identifier = its addon_linked_with.","[].dinerOrderDetail[].order_items[].is_refunded":"0 | 1. 1 = the line was (partly) refunded.","[].dinerOrderDetail[].order_items[].refund_quantity":"int. Refunded quantity.","[].dinerOrderDetail[].order_items[].refund_reason":"string | null. Refund reason.","[].dinerOrderDetail[].order_items[].note":"string | null. Line note. Shown.","[].dinerOrderDetail[].order_items[].addon_linked_with":"string | null. On an add-on row: the identifier of its main line. A main line with add-ons carries its own identifier here.","[].dinerOrderDetail[].order_items[].discount_pct":"number. Line discount %.","[].dinerOrderDetail[].order_items[].discount_amount":"number. Line discount per unit.","[].dinerOrderDetail[].order_items[].modifier":"array. Chosen modifier options. [] = none.","[].dinerOrderDetail[].order_items[].transferred_to":"int. customer_invoice_id of the order this line was moved to. 0 = not transferred. web-pos shows \"N Qty transferred to invoice# X\".","[].dinerOrderDetail[].order_items[].transferred_to_uuid":"string | null. uuid of that order.","[].dinerOrderDetail[].order_items[].transferred_to_uuid_list":"string[]. Every order uuid this line was moved to.","[].dinerOrderDetail[].order_items[].transferred_quantities":"number[]. Quantity per transfer (same order as transferred_to_uuid_list).","[].dinerOrderDetail[].order_items[].transfer_recipients":"int[]. Recipient ids sent by the POS with the transfers. NOT USED by web-pos history.","[].dinerOrderDetail[].order_items[].created_at":"int. Unix seconds the line was added.","[].dinerOrderDetail[].order_items[].updated_at":"int. Unix seconds of the last change.","[].dinerOrderDetail[].order_items[].is_prepared":"0 | 1 | null. Marked prepared on the KDS.","[].dinerOrderDetail[].order_items[].preparation_time":"int | null. Minutes. NOT USED by web-pos history.","[].dinerOrderDetail[].order_items[].is_weightage_item":"0 | 1 | null. 1 = sold by weight.","[].dinerOrderDetail[].order_items[].unit_id":"int | null. Unit of the weighted product.","[].dinerOrderDetail[].order_items[].type":"string | null. Product type = category name(s) of the product. NOT USED by web-pos history.","[].dinerOrderDetail[].order_items[].label_id":"int | null. Kitchen label (list_labels).","[].dinerOrderDetail[].order_items[].item_status":"string. \"active\" or \"disposition\" (removed with a disposition reason: shown in red, not billed).","[].dinerOrderDetail[].order_items[].linked_uuid":"string | null. Order uuid this line is linked to (transfer/split). NOT USED by web-pos history.","[].dinerOrderDetail[].order_items[].original_linked_uuid":"string | null. First linked order uuid. NOT USED by web-pos history.","[].dinerOrderDetail[].order_items[].disposition_type":"string | null. Disposition kind, shown when item_status = \"disposition\".","[].dinerOrderDetail[].order_items[].image":"string | null. Image URL. A URL ending in /uploads/ means no image.","[].dinerOrderDetail[].order_items[].is_combo":"0 | 1 | 2 | null. 1 = fixed-price combo, 2 = combo priced per chosen child. Children are in combo_items.","[].dinerOrderDetail[].order_items[].is_deleted":"0 | 1 | null. 1 = REMOVED from the order: web-pos shows it struck through and does not bill it.","[].dinerOrderDetail[].order_items[].deleted_at":"int | null. Unix seconds of the removal.","[].dinerOrderDetail[].order_items[].deleted_qty":"number | null. Quantity removed.","[].dinerOrderDetail[].order_items[].coupon_amount":"number. Coupon amount on this line.","[].dinerOrderDetail[].order_items[].coupon_id":"int. Coupon on this line (list_selling_coupons). 0 = none.","[].dinerOrderDetail[].order_items[].coupon_code":"string | null. Coupon code on this line.","[].dinerOrderDetail[].order_items[].updated_by_user_ids":"int[]. POS users who changed the line.","[].dinerOrderDetail[].order_items[].re":"string | null. Cancel/return reason stored on the line.","[].dinerOrderDetail[].order_items[].ri":"0 | 1. 1 = stock returned to inventory on cancel.","[].dinerOrderDetail[].order_items[].is_wastage":"0 | 1. 1 = written off as wastage.","[].dinerOrderDetail[].order_items[].discount_id":"int | null. Discount rule applied (list_discounts).","[].dinerOrderDetail[].order_items[].discount_name":"string | null. Name of that discount.","[].dinerOrderDetail[].order_items[].round":"int | null. Dine-in round in which the line was added.","[].dinerOrderDetail[].order_items[].combo_items":"array | null. Children of a combo line (is_combo 1/2); null otherwise."}},"parameters":{"type":"object","properties":{"table_id":{"type":"number","description":"Optional."},"brand_id":{"type":"number","description":"Optional."}},"required":[]},"module":"dine","safety":"read"},{"name":"get_table_order","description":"GET /api/dine-table-order/:table. Why: The latest open (or done) ticket of ONE table at the JWT session branch, with its lines — what to reopen when adding items to an occupied table. Table id comes from list_selling_tables / list_dine_orders. Only the newest ticket is returned; when a table has several open tickets use list_dine_orders (dinerOrderDetail lists them all). Session branch only (a table of another branch returns []). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_table_order\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/dine-table-order/:table","why":"The latest open (or done) ticket of ONE table at the JWT session branch, with its lines — what to reopen when adding items to an occupied table. Table id comes from list_selling_tables / list_dine_orders. Only the newest ticket is returned; when a table has several open tickets use list_dine_orders (dinerOrderDetail lists them all). Session branch only (a table of another branch returns []).","response":{"description":"A BARE JSON ARRAY with at most ONE ticket: the newest invoice with status open or done for that table at the session branch and your tenant, with order_items. [] when the table is free. Note created_at here is an ISO string, while list_dine_orders returns Unix seconds.","fields":{"[]":"array. The table's latest open or done ticket (at most ONE element, newest created_at), or [] when the table has no open/done ticket at the session branch.","[].uuid":"string. Ticket uuid. Use with get_invoice / get_invoice_items; it is also in list_dine_orders[].uuid for the same table.","[].id":"int. The branch order number shown to staff (customer_invoice_id) — NOT the table id.","[].order_no":"int. Order sequence number.","[].table_id":"int. Table id (= the :table you passed).","[].status":"string. \"open\" (sent to kitchen, unpaid) or \"done\".","[].order_type":"string. Order type title (list_selling_order_types), e.g. \"Dine In\".","[].dining_guests":"int. Guests on the ticket (0 = not set).","[].guest_name":"string | null. Guest name typed on the ticket.","[].plate_number":"string | null. Car plate (drive-thru); null for tables.","[].token_number":"string | null. Token / pager number.","[].items_number":"int. Number of item lines.","[].subtotal":"number. Total before tax.","[].vat":"number. Tax amount.","[].payable":"number. Amount to pay.","[].discountamount":"number. Order discount amount.","[].discountpct":"number. Order discount percent.","[].items_discount_amount":"number. Sum of line discounts (added by the server).","[].paid":"number. Amount paid so far.","[].change":"number. Change given.","[].is_paid":"string. \"0\" / \"1\" (string, not boolean).","[].is_refunded":"0 | 1. Refund flag.","[].is_updated":"0 | 1. Ticket edited after first save.","[].payment_method":"string | null. JSON text of tenders once paid; null while open.","[].note":"string | null. Order note.","[].created_at":"string. ISO timestamp (UTC).","[].updated_at":"string. ISO timestamp (UTC).","[].done_at":"string | null. When the ticket was marked done.","[].period":"string | null. Business-day period label.","[].customer_id":"int. Tenant id.","[].location_id":"int. Branch id = JWT session branch.","[].user":"string. Email of the user who created it.","[].user_id":"int. User id who created it.","[].created_by_user_id":"int. Creator user id.","[].close_by_user_id":"int | null. User who closed it.","[].modified_by_user_ids":"array. Users who edited it ([] when none).","[].crm_user_id":"int. Attached CRM customer id (0 = none).","[].crm_user_name":"string. Attached CRM customer name.","[].discount_id":"int | null. Applied discount id.","[].channel_id":"int. Sales channel id (0 = POS).","[].channel_name":"string. Sales channel name.","[].aggregator_order_id":"string | null. Aggregator order id.","[].order_id":"string | null. External order id.","[].invoice_id":"string | null. External invoice id.","[].is_accepted":"0 | 1. Accepted flag (aggregator orders).","[].exchange_rate":"number. Currency exchange rate (0 = none).","[].is_kds_printed":"0 | 1. Kitchen ticket printed.","[].is_invoice_printed":"0 | 1. Receipt printed.","[].is_vat_on_discount_exclude":"0 | 1. Tax-on-discount mode flag.","[].info_name":"string. Business name on the receipt.","[].info_address":"string. Business address on the receipt.","[].info_phone":"string. Business phone on the receipt.","[].info_email":"string. Business email on the receipt.","[].card_digits":"string. Last card digits.","[].mac_addresses":"string. Device MAC addresses.","[].order_items":"array. Lines of the ticket (combo children nested in combo_items).","[].order_items[].id":"int. Line id.","[].order_items[].identifier":"string. Line uuid.","[].order_items[].name":"string. Product name.","[].order_items[].name_localize":"string | null. Localized name.","[].order_items[].product_id":"int. Product id (list_selling_products).","[].order_items[].type":"string | null. Product type/category label.","[].order_items[].qty":"number. Quantity.","[].order_items[].price":"number. Unit price.","[].order_items[].actual_price":"number. Unit price before line discount.","[].order_items[].base_price":"number | null. Base price.","[].order_items[].subtotal":"number. Line total.","[].order_items[].line_total":"number | null. Line total incl. tax.","[].order_items[].line_vat":"number | null. Line tax.","[].order_items[].line_total_without_vat":"number | null. Line total excl. tax.","[].order_items[].vat":"number. Tax rate.","[].order_items[].discount_pct":"number. Line discount percent.","[].order_items[].discount_amount":"number. Line discount amount.","[].order_items[].discount_id":"int | null. Line discount id.","[].order_items[].discount_name":"string | null. Line discount name.","[].order_items[].coupon_amount":"number. Coupon amount on the line.","[].order_items[].coupon_id":"int. Coupon id (0 = none).","[].order_items[].coupon_code":"string. Coupon code.","[].order_items[].modifier":"array. Modifiers chosen ([] = none).","[].order_items[].extras":"string | null. Extras JSON text.","[].order_items[].note":"string | null. Line note.","[].order_items[].is_addon":"0 | 1. 1 = add-on line attached to another line.","[].order_items[].addon_linked_with":"string. identifier of the parent line (its own identifier when not an add-on).","[].order_items[].is_combo":"0 | 1 | 2 | null. Combo parent flag.","[].order_items[].combo_items":"array | null. Combo child lines (same fields).","[].order_items[].round":"int | null. Course / round the line was saved in (1 = first save; null on old tickets).","[].order_items[].is_prepared":"0 | 1. Kitchen marked prepared.","[].order_items[].preparation_time":"int | null. Preparation time.","[].order_items[].label_id":"int | null. Kitchen label id.","[].order_items[].is_updated":"0 | 1. Line edited after save.","[].order_items[].updated_qty":"number. Quantity change of the edit.","[].order_items[].updated_by_user_ids":"array. Users who edited the line.","[].order_items[].is_deleted":"0 | 1. Line voided (still returned).","[].order_items[].deleted_at":"int | null. Unix time of the void.","[].order_items[].deleted_qty":"number. Quantity voided.","[].order_items[].is_refunded":"0 | 1. Line refunded.","[].order_items[].refund_quantity":"number. Quantity refunded.","[].order_items[].refund_reason":"string | null. Refund reason.","[].order_items[].is_wastage":"0 | 1. Wastage flag.","[].order_items[].transferred_to":"int. Legacy transfer target (0 = none).","[].order_items[].transferred_to_uuid":"string | null. Legacy transfer target ticket uuid.","[].order_items[].transferred_to_uuid_list":"string[]. Tickets this line was (partly) transferred to.","[].order_items[].transferred_quantities":"number[]. Quantity moved to each ticket in transferred_to_uuid_list.","[].order_items[].transfer_recipients":"int[]. Recipient ids of each transfer.","[].order_items[].is_weightage_item":"0 | 1 | null. Sold by weight (from the product).","[].order_items[].unit_id":"int | null. Unit id (from the product).","[].order_items[].invoice_uuid":"string. uuid of the ticket.","[].order_items[].re":"string | null. Internal re flag.","[].order_items[].ri":"int. Internal ri flag.","[].order_items[].created_at":"int. Unix time the line was created.","[].order_items[].updated_at":"int. Unix time the line was updated."},"example":[{"token_number":null,"id":23,"items_number":3,"aggregator_order_id":null,"is_accepted":0,"exchange_rate":0,"subtotal":31,"vat":0,"payable":31,"discountamount":0,"discountpct":0,"paid":0,"channel_id":0,"change":0,"note":"dev1 transfer-fields e2e dine","uuid":"b7b056e0-3c5f-437d-9258-968b6cc34a6e","created_at":"2026-08-26T10:03:08.000Z","customer_id":437,"location_id":907,"info_name":"","info_address":"","info_phone":"","info_email":"","status":"open","user":"admin@devnexus.co","user_id":4734,"table_id":6194,"payment_method":null,"order_id":null,"invoice_id":null,"done_at":null,"period":null,"is_kds_printed":0,"is_invoice_printed":0,"plate_number":null,"order_no":1,"is_paid":"0","is_refunded":0,"guest_name":"Transfer E2E Dine","is_updated":0,"card_digits":"","mac_addresses":"","crm_user_id":0,"updated_at":"2026-08-26T10:03:10.000Z","crm_user_name":"","created_by_user_id":4734,"close_by_user_id":null,"modified_by_user_ids":[],"discount_id":null,"order_type":"Dine In","is_vat_on_discount_exclude":0,"dining_guests":2,"channel_name":"","order_items":[{"id":25504110,"identifier":"5f1c0c66-2efb-4335-ab40-99ce6ceaa433","name":"Flat White","name_localize":null,"actual_price":11,"base_price":null,"price":11,"is_weightage_item":0,"unit_id":0,"qty":1,"subtotal":11,"line_total":null,"line_vat":null,"line_total_without_vat":null,"invoice_uuid":"b7b056e0-3c5f-437d-9258-968b6cc34a6e","product_id":257506,"extras":null,"vat":0,"is_updated":0,"updated_qty":0,"is_addon":0,"is_refunded":0,"refund_quantity":0,"refund_reason":null,"note":null,"addon_linked_with":"5f1c0c66-2efb-4335-ab40-99ce6ceaa433","discount_pct":0,"discount_amount":0,"modifier":[],"transferred_to":0,"transferred_to_uuid":null,"transferred_to_uuid_list":["3ccd598a-f022-4939-931f-eedd132e79b6","f2ab957f-2cba-4b48-a534-8681b8934a1a","0ea2c766-fd6d-418d-9e04-70aa6a60fa1a"],"transferred_quantities":[2,4,1.5],"transfer_recipients":[12,43,60],"created_at":1787738588,"updated_at":1787738588,"is_prepared":0,"preparation_time":null,"type":"Espresso-Based Drinks","label_id":50,"is_combo":0,"is_deleted":0,"deleted_at":null,"deleted_qty":0,"coupon_amount":0,"coupon_id":0,"coupon_code":"","updated_by_user_ids":[],"re":null,"ri":0,"is_wastage":0,"discount_id":null,"discount_name":null,"round":null,"combo_items":null}],"items_discount_amount":0}]},"parameters":{"type":"object","properties":{"table":{"type":"number","description":"Required."}},"required":["table"]},"module":"dine","safety":"read"},{"name":"list_orders_history","description":"GET /api/v2/orders-history. Why: The order list of the session branch — exactly what the web-pos Invoices screen loads: every order (open, completed, cancelled) newest first, 30 per page, with lines, payments, customer and a summary. Use it to find an order (by status, invoice number, customer, phone, plate, shift, brand or business day) and read it in full; then act on its uuid with cancel_invoice, refund_invoice, update_invoice_payment_methods or get_invoice_activities. Paginated with current_page / page_limit. order_type cannot be filtered on the server — filter the rows yourself by list_selling_order_types title. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_orders_history\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/orders-history","why":"The order list of the session branch — exactly what the web-pos Invoices screen loads: every order (open, completed, cancelled) newest first, 30 per page, with lines, payments, customer and a summary. Use it to find an order (by status, invoice number, customer, phone, plate, shift, brand or business day) and read it in full; then act on its uuid with cancel_invoice, refund_invoice, update_invoice_payment_methods or get_invoice_activities. Paginated with current_page / page_limit. order_type cannot be filtered on the server — filter the rows yourself by list_selling_order_types title.","response":{"description":"{ status, message, data: { current_page, records_per_page, total_records, force_location_ids, summary, orders[] } }. orders[] newest first (invoice number DESC) with items[] and payments. When nothing matches: data = { total_records: 0, force_location_ids, summary } (no orders, no current_page). Errors: HTTP 400 { status:false, message } for a bad date or location, HTTP 403 for a branch outside the key.","fields":{"status":"boolean. true = success (also when nothing matched). false = 400 invalid date / invalid location, or 403 branch outside the key.","message":"string. \"Orders history\" | \"No records found\" | the error text.","data":"object | null. null on a 400/403 error.","data.current_page":"string (not a number, e.g. \"1\"). Page returned. MISSING when total_records = 0.","data.records_per_page":"int. Page size used. MISSING when total_records = 0.","data.total_records":"int. Orders matching the filters on all pages. Pages = ceil(total_records / records_per_page).","data.force_location_ids":"int[]. Branch id(s) actually queried. NOT USED by web-pos.","data.summary":"object. Totals over EVERY order matching the filters (all pages, not just this one).","data.summary.total_orders":"int. Count of matching orders (any status).","data.summary.total_revenue":"number. GROSS amount (payable + order discount + line discounts) of all matching orders, cancelled ones included. Not net sales.","data.summary.completed_orders":"int. Matching orders with status \"completed\".","data.summary.completed_revenue":"number. Gross amount of the completed ones.","data.summary.open_orders":"int. Matching orders with status \"open\" or \"pending\".","data.summary.open_revenue":"number. Gross amount of the open/pending ones.","data.summary.void_orders":"int. Matching orders with status \"cancelled\", \"canceled\", \"void\" or \"voided\".","data.summary.void_revenue":"number. Gross amount of the cancelled ones.","data.orders":"array. This page of orders, newest first. MISSING (not []) when total_records = 0.","data.orders[].id":"string. The LAST 12 CHARACTERS of uuid. NOT a database id and NOT the order number. web-pos uses it only as the list key. Never send it to another tool — send uuid.","data.orders[].oid":"int. Same value as customer_invoice_id. web-pos shows it as the detail title \"Order #oid\".","data.orders[].customer_invoice_id":"int. THE INVOICE NUMBER (per tenant, grows by 1). Search it with the invoice_no param. items[].transferred_to points to this number.","data.orders[].brand_id":"int | null. Brand of the order (list_selling_brands). Filter with brand_id. web-pos reuses it when an open order is reopened (Pay Now).","data.orders[].exchange_rate":"number | null. NOT USED. USD exchange rate saved with the order.","data.orders[].token_number":"int | null. Queue token of kiosk/aggregator orders. null in POS orders. NOT USED by web-pos history.","data.orders[].is_wastage":"0 | 1. 1 = the order was cancelled AS WASTAGE (stock written off, not returned).","data.orders[].paid_in_usd":"number | null. NOT USED. Amount tendered in USD.","data.orders[].is_accepted":"0 | 1 | null. Online/aggregator order accepted by the branch. NOT USED by web-pos history.","data.orders[].external_order_ready":"0 | 1. Aggregator order marked ready. NOT USED by web-pos history.","data.orders[].is_discount_on_total":"0 | 1 | null. NOT USED. How the order discount was entered.","data.orders[].items_number":"int. Number of lines when the order was saved (can differ from items.length).","data.orders[].aggregator_order_id":"string | null. Order id at the delivery aggregator. NOT USED by web-pos history.","data.orders[].subtotal":"number. Total of the lines before the order discount. Shown as Subtotal.","data.orders[].vat":"number. Tax amount (tax is out of scope). Shown.","data.orders[].payable":"number. GRAND TOTAL the customer owes. Shown as Total. Refunds do NOT lower it.","data.orders[].discountamount":"number. Order-level discount amount. Shown when > 0.","data.orders[].items_discount_amount":"number. Sum of the line discounts, recomputed from the lines by the server.","data.orders[].is_ticket_available":"0 | 1. NOT USED.","data.orders[].is_wallet_used":"0 | 1. 1 = paid (partly) from the customer's wallet.","data.orders[].discountpct":"number. Order-level discount %. web-pos restores it when the order is reopened (Pay Now).","data.orders[].paid":"number. Amount tendered.","data.orders[].change":"number. Change handed back. Shown when > 0.","data.orders[].note":"string | null. Order note. Shown. When an order is cancelled the server rewrites it to \"<reason> **canceled by: <username>\".","data.orders[].uuid":"string. THE ORDER KEY. Pass it to get_invoice_activities, get_invoice, get_invoice_items, cancel_invoice, refund_invoice and update_invoice_payment_methods.","data.orders[].deliverect_id":"string | null. NOT USED. Deliverect order id.","data.orders[].created_at":"int. Unix seconds (UTC) when the order was created. Shown as date + time.","data.orders[].updated_at":"int. Unix seconds of the last change.","data.orders[].customer_id":"int. NOT USED. Tenant id.","data.orders[].location_id":"int. Branch id of the order.","data.orders[].guest_name":"string | null. Customer name typed on the order. Shown as Name. Search with guest_name.","data.orders[].info_name":"string | null. Name sent by the POS with the order. NOT shown by web-pos history.","data.orders[].info_address":"string | null. Address sent with the order (delivery). NOT shown by web-pos history.","data.orders[].info_phone":"string | null. Phone sent with the order. Shown as Phone.","data.orders[].info_email":"string | null. E-mail sent with the order. Shown as Email.","data.orders[].status":"string. Order state: \"open\" (saved, not paid), \"completed\" (paid and closed), \"cancelled\" (voided by cancel_invoice), \"canceled\" (older cancel path, also void). \"pending\"/\"done\" can appear on online/dine tickets. A refund does NOT change it. See the rules.","data.orders[].by":"string | null. Source label of aggregator orders. NOT USED.","data.orders[].external_order_id":"string | null. Same as aggregator_order_id (\"\" becomes null). NOT USED by web-pos history.","data.orders[].user":"string. Login (e-mail) of the POS user whose session saved the order.","data.orders[].table_id":"int | null. Table of a dine-in order (list_selling_tables / list_dine_orders row id). 0 or null = no table. web-pos shows the table name in the list.","data.orders[].order_id":"int | null. External/legacy order id (\"\" becomes null). NOT USED.","data.orders[].payment_method":"array | null. One { \"<method name>\": amount } per payment row, oldest first. Keys are list_selling_payment_methods names AS SAVED (case varies: \"cash\", \"Cash\", \"card\"). A negative amount is a refund row. null = no payment rows (open/unpaid orders). Old rows can show { \"0\": 0 }. Shown as badges.","data.orders[].burn_points":"number. Loyalty points redeemed. Shown when > 0.","data.orders[].earn_points":"number. Loyalty points earned. Shown when > 0.","data.orders[].burn_points_amount":"number. Money value of the redeemed points. Shown with burn_points.","data.orders[].available_points":"number. NOT USED. Customer's point balance saved with the order.","data.orders[].available_points_amount":"number. NOT USED. Money value of that balance.","data.orders[].charges_fee":"number | null. Service charge amount. Shown.","data.orders[].delivery_charges":"number | null. Delivery fee.","data.orders[].phone_number":"string. Customer phone on the order. Search with phone_number.","data.orders[].loyalty_mobile_number":"string. NOT USED. Loyalty member mobile.","data.orders[].loyalty_mobile_code":"string. NOT USED. Dial code of that mobile.","data.orders[].crm_delivery_area":"string | null. NOT USED. Delivery area name.","data.orders[].invoice_id":"int | null. NOT USED. Legacy invoice id.","data.orders[].done_at":"int | null. Unix seconds when the kitchen bumped the order (KDS done). null = not bumped yet.","data.orders[].period":"string | null. NOT USED.","data.orders[].is_kds_printed":"0 | 1. Kitchen ticket printed. NOT USED by web-pos history.","data.orders[].is_invoice_printed":"0 | 1. Receipt printed. NOT USED by web-pos history.","data.orders[].plate_number":"string | null. Car plate (drive-through). Search with plate_number.","data.orders[].order_no":"int. SHORT ORDER NUMBER shown in the list (\"order_no\", falls back to oid). The counter restarts, so it is NOT unique — never use it as a key and it is not what invoice_no searches.","data.orders[].created_by_user_id":"int | null. POS user who created the order (list_pos_users). Shown as Created By.","data.orders[].close_by_user_id":"int | null. POS user who paid/closed or cancelled it (list_pos_users). Shown as Closed By.","data.orders[].modified_by_user_ids":"int[]. POS users who edited the order. NOT USED by web-pos history.","data.orders[].is_vat_on_discount_exclude":"0 | 1. Tax setting (out of scope). NOT USED.","data.orders[].order_type":"string | null. Order type TITLE as saved (e.g. \"Dine In\", \"takeaway\"). Match it case-insensitively to list_selling_order_types title. null on old orders. The server CANNOT filter by it — filter this field yourself.","data.orders[].channel_id":"int. Sales channel id (list_selling_channels). web-pos saves 0.","data.orders[].tip_amount":"number. Tip on the order.","data.orders[].dining_guests":"int | null. Number of guests (dine-in).","data.orders[].channel_name":"string. Sales channel name (\"\" when none).","data.orders[].is_paid":"1 | 0 | null. 1 = paid. null = NOT paid (the column holds 0/NULL for unpaid orders); 0 only on old rows. Treat anything but 1 as unpaid.","data.orders[].card_digits":"string. NOT USED. Last card digits.","data.orders[].crm_user_id":"int. Customer id (list_pos_crm_users). 0 = no customer. Shown as CRM User. Filter with crm_user_id.","data.orders[].crm_user_name":"string. Customer name (\"\" when none). Search with crm_user_name.","data.orders[].discount_reason":"string | null. Reason typed for the order discount.","data.orders[].row_discount":"0 | 1. 1 = line discounts also cover the line's modifiers/extras; 0 = product price only.","data.orders[].paid_at":"int | null. Unix seconds of payment. null on many paid orders — use is_paid.","data.orders[].coupon_amount":"number. Coupon discount on the order.","data.orders[].coupon_id":"int. Coupon used (list_selling_coupons id). 0 = none.","data.orders[].coupon_code":"string | null. Code of that coupon.","data.orders[].printed_invoice_receipts":"int. NOT USED. Receipt print count.","data.orders[].printed_kds_receipts":"int. NOT USED. Kitchen print count.","data.orders[].app_version":"string | null. NOT USED. Version of the app that created the order.","data.orders[].app_os_version":"string | null. NOT USED. OS version of that device.","data.orders[].build_number":"string | null. NOT USED. Build of that app.","data.orders[].os_type":"string | null. NOT USED. OS of that device.","data.orders[].round":"int | null. Dine-in round (course) counter.","data.orders[].rider_pickup_at":"int | null. Unix seconds the delivery rider picked up. NOT USED by web-pos history.","data.orders[].rider_arrived_at":"int | null. Unix seconds the rider arrived. NOT USED by web-pos history.","data.orders[].shift_id":"int | null. Cashier shift of the order (list_shifts). Filter with shift_id. null when the shift is not found at this branch.","data.orders[].cashier_user_id":"int | null. User who opened that shift.","data.orders[].cashier_name":"string | null. UNRELIABLE (the server looks the shift user up in the customer table) — use cashier_user_id with list_pos_users instead.","data.orders[].shift_clock_in_at":"string | null. Date-time the shift started.","data.orders[].is_order_rebump":"0 | 1. 1 = done_at is null (the kitchen has not bumped it).","data.orders[].order_type_icon_key":"string | null. Iconify key of the order type icon.","data.orders[].notes":"string[]. Order notes list. [] = none.","data.orders[].grand_total":"number. Same as payable.","data.orders[].payment_methods":"array | null. Same rows as payment_method with tips: [{ \"<method name>\": { amount, tip_amount } }].","data.orders[].payments":"array | null. Same rows, flat and easiest to read: [{ payment_method, amount, tip_amount }]. null = no payment.","data.orders[].payments[].payment_method":"string. Method name as saved (list_selling_payment_methods name, any case).","data.orders[].payments[].amount":"number. Amount of this row; negative = refund.","data.orders[].payments[].tip_amount":"number. Tip on this row (0 when none).","data.orders[].order_type_icon":"string | null. PNG URL of the order type icon.","data.orders[].items":"array. The order lines: main lines, add-on rows and removed lines. Combo children are NOT here (see combo_items). Lines of archived products are left out.","data.orders[].items[].id":"int. Line id (invoice_items.id).","data.orders[].items[].identifier":"string. Line uuid. Add-on rows point to it with addon_linked_with.","data.orders[].items[].name":"string. Product name as sold (web-pos hides the text after \" - \").","data.orders[].items[].name_localize":"string | null. Localized name.","data.orders[].items[].base_price":"number | null. NOT USED.","data.orders[].items[].actual_price":"number. Unit price of the product alone (VAT included). web-pos shows qty × actual_price.","data.orders[].items[].preparation_time":"int | null. Minutes. NOT USED by web-pos history.","data.orders[].items[].price":"number. Unit price as saved by the POS. web-pos shows actual_price instead.","data.orders[].items[].qty":"number. Quantity (weight for weighted products). A partial refund lowers it.","data.orders[].items[].is_weightage_item":"0 | 1 | null. 1 = sold by weight.","data.orders[].items[].unit_id":"int | null. Unit of the weighted product.","data.orders[].items[].subtotal":"number. Line total as saved.","data.orders[].items[].line_total":"number | null. Tax-era total, null in most data. NOT USED.","data.orders[].items[].line_vat":"number | null. Tax (out of scope). NOT USED.","data.orders[].items[].line_total_without_vat":"number | null. NOT USED.","data.orders[].items[].invoice_uuid":"string. uuid of the order.","data.orders[].items[].product_id":"int. Product (list_selling_products; add-on rows → list_selling_addons).","data.orders[].items[].extras":"array | object | null. Priced extras [{ name, actual_price, price, quantity, discount_pct }]. Old non-master rows keep a { materialId: qty } map. null = none.","data.orders[].items[].extras[].name":"string. Extra name. Shown.","data.orders[].items[].extras[].actual_price":"number. Unit price of the extra. Shown as quantity × actual_price.","data.orders[].items[].extras[].price":"number. Price as saved.","data.orders[].items[].extras[].quantity":"number. Quantity of the extra.","data.orders[].items[].extras[].discount_pct":"number. Line discount % applied to the extra (0 when row_discount = 0).","data.orders[].items[].vat":"number. Tax (out of scope).","data.orders[].items[].is_updated":"0 | 1. Quantity changed after the first save.","data.orders[].items[].updated_qty":"int. The changed quantity.","data.orders[].items[].is_addon":"0 | 1. 1 = ADD-ON ROW: show it under the main line whose identifier = its addon_linked_with.","data.orders[].items[].is_refunded":"0 | 1. 1 = the line was (partly) refunded.","data.orders[].items[].refund_quantity":"int. Refunded quantity.","data.orders[].items[].refund_reason":"string | null. Refund reason.","data.orders[].items[].note":"string | null. Line note. Shown.","data.orders[].items[].addon_linked_with":"string | null. On an add-on row: the identifier of its main line. A main line with add-ons carries its own identifier here.","data.orders[].items[].discount_pct":"number. Line discount %.","data.orders[].items[].discount_amount":"number. Line discount per unit.","data.orders[].items[].modifier":"array. Chosen modifier options. [] = none.","data.orders[].items[].modifier[].name":"string. Option name. Shown.","data.orders[].items[].modifier[].actual_price":"number. Unit price of the option.","data.orders[].items[].modifier[].price":"number. Price as saved.","data.orders[].items[].modifier[].quantity":"number. Quantity of the option.","data.orders[].items[].modifier[].discount_pct":"number. Line discount % applied to the option (0 when row_discount = 0).","data.orders[].items[].modifier[].items":"array | absent. Nested options under this modifier ({ name, quantity, actual_price }); web-pos lists them when present.","data.orders[].items[].transferred_to":"int. customer_invoice_id of the order this line was moved to. 0 = not transferred. web-pos shows \"N Qty transferred to invoice# X\".","data.orders[].items[].transferred_to_uuid":"string | null. uuid of that order.","data.orders[].items[].transferred_quantity":"number. Quantity moved.","data.orders[].items[].transferred_to_uuid_list":"string[]. Every order uuid this line was moved to.","data.orders[].items[].transferred_quantities":"number[]. Quantity per transfer (same order as transferred_to_uuid_list).","data.orders[].items[].transfer_recipients":"int[]. Recipient ids sent by the POS with the transfers. NOT USED by web-pos history.","data.orders[].items[].created_at":"int. Unix seconds the line was added.","data.orders[].items[].updated_at":"int. Unix seconds of the last change.","data.orders[].items[].is_prepared":"0 | 1 | null. Marked prepared on the KDS.","data.orders[].items[].type":"string | null. Product type = category name(s) of the product. NOT USED by web-pos history.","data.orders[].items[].label_id":"int | null. Kitchen label (list_labels).","data.orders[].items[].item_status":"string. \"active\" or \"disposition\" (removed with a disposition reason: shown in red, not billed).","data.orders[].items[].linked_uuid":"string | null. Order uuid this line is linked to (transfer/split). NOT USED by web-pos history.","data.orders[].items[].original_linked_uuid":"string | null. First linked order uuid. NOT USED by web-pos history.","data.orders[].items[].disposition_type":"string | null. Disposition kind, shown when item_status = \"disposition\".","data.orders[].items[].image":"string | null. Image URL. A URL ending in /uploads/ means no image.","data.orders[].items[].is_combo":"0 | 1 | 2 | null. 1 = fixed-price combo, 2 = combo priced per chosen child. Children are in combo_items.","data.orders[].items[].is_deleted":"0 | 1 | null. 1 = REMOVED from the order: web-pos shows it struck through and does not bill it.","data.orders[].items[].deleted_at":"int | null. Unix seconds of the removal.","data.orders[].items[].deleted_qty":"number | null. Quantity removed.","data.orders[].items[].coupon_amount":"number. Coupon amount on this line.","data.orders[].items[].coupon_id":"int. Coupon on this line (list_selling_coupons). 0 = none.","data.orders[].items[].coupon_code":"string | null. Coupon code on this line.","data.orders[].items[].updated_by_user_ids":"int[]. POS users who changed the line.","data.orders[].items[].re":"string | null. Cancel/return reason stored on the line.","data.orders[].items[].ri":"0 | 1. 1 = stock returned to inventory on cancel.","data.orders[].items[].is_wastage":"0 | 1. 1 = written off as wastage.","data.orders[].items[].discount_id":"int | null. Discount rule applied (list_discounts).","data.orders[].items[].discount_name":"string | null. Name of that discount.","data.orders[].items[].round":"int | null. Dine-in round in which the line was added.","data.orders[].items[].combo_items":"array | null. Children of a combo line (is_combo 1/2); null otherwise.","data.orders[].items[].combo_items[].<field>":"Each combo child row has the SAME fields and meanings as the parent items[] row (id, product_id, name, qty, actual_price, modifier[], …); only children of a combo line (is_combo != 0) appear here."},"master_rules":["web-pos Invoices screen (utils/useOrderFetcher.js): GET with page_limit=30, current_page=N and NO date → every order of the session branch, newest first (customer_invoice_id DESC). It loads the next page on scroll while a page returns 30 rows, and refetches every 2 minutes.","Status tabs All / Open / Completed / Cancelled send status=open|completed|cancelled (All sends nothing). Search box: ONE field at a time — invoice_no, crm_user_name, mobile_number, phone_number, guest_name, plate_number — sent as that query param.","Order Type dropdown: web-pos sends order_type=<title> but the SERVER IGNORES IT; web-pos filters the loaded rows itself (order_type == list_selling_order_types title). Do the same: filter data.orders[].order_type case-insensitively.","Paging for agents: keep calling with current_page+1 until a page returns fewer than page_limit rows or you have total_records rows. page and limit are NOT params (they are silently ignored).","Sending date switches to ONE business day of the branch (opening → closing hours, may cross midnight) AND, unless you also send status, keeps only completed orders; the default page size becomes 50. There is no from/to range. Send status=all to see every status of that day.","Identities: uuid = the key for every other tool. customer_invoice_id (= oid) = invoice number, searchable with invoice_no. order_no = short counter shown in the list, restarts, NOT unique. id = last 12 chars of uuid. token_number = kiosk/aggregator token, usually null. This route cannot search by uuid — to go from a uuid to the full order call get_invoice (gives customer_invoice_id) then this tool with invoice_no.","Status → actions in web-pos (OrderDetails.jsx): open → Pay Now (loads the order into the cart; paying re-saves the SAME uuid via sync_invoice) and Cancel; completed → Cancel and Change Payment Method (only when the is_change_payment_method_enabled setting is 1); cancelled/canceled → nothing (read only). Print / Download / Preview are local (no API) for every status. Refund is not offered on this screen (the button is commented out), but refund_invoice exists.","Actions for agents: cancel → cancel_invoice(uuid); refund → refund_invoice(uuid); change tenders → update_invoice_payment_methods(uuid); trail → get_invoice_activities(uuid). A cancel sets status \"cancelled\" and rewrites note. A refund does NOT change status or payable: look for items[].is_refunded / refund_quantity and negative payment rows.","Lines: group add-on rows (is_addon = 1) under the main line whose identifier = addon_linked_with. is_deleted = 1 or item_status = \"disposition\" lines are shown but not billed. transferred_to > 0 = quantity moved to invoice number transferred_to.","Links: table_id → list_selling_tables / list_dine_orders; crm_user_id → list_pos_crm_users; payment_method keys → list_selling_payment_methods names; order_type → list_selling_order_types title; items[].product_id → list_selling_products; coupon_id → list_selling_coupons; brand_id → list_selling_brands; created_by_user_id / close_by_user_id → list_pos_users; shift_id → list_shifts.","Offline orders: web-pos also shows unsynced local orders at the top of this list; they are NOT on the server, so this tool never returns them."],"example":{"status":true,"message":"Orders history","data":{"current_page":"1","records_per_page":10,"total_records":5,"force_location_ids":[907],"summary":{"total_orders":5,"total_revenue":461,"completed_orders":0,"completed_revenue":0,"open_orders":0,"open_revenue":0,"void_orders":5,"void_revenue":461},"orders":[{"id":"3609c849e2e4","oid":19,"customer_invoice_id":19,"brand_id":null,"exchange_rate":null,"token_number":null,"is_wastage":0,"paid_in_usd":null,"is_accepted":null,"external_order_ready":0,"is_discount_on_total":null,"items_number":2,"aggregator_order_id":null,"subtotal":106,"vat":0,"payable":106,"discountamount":0,"items_discount_amount":1.1,"is_ticket_available":1,"is_wallet_used":0,"discountpct":0,"paid":106,"change":0,"note":"qa cancel **canceled by: Nexus Cafe","uuid":"3a00fb3a-0c78-444a-abef-3609c849e2e4","deliverect_id":null,"created_at":1786948030,"updated_at":1786948031,"customer_id":437,"location_id":907,"guest_name":"QA rd 0","info_name":"QA","info_address":"","info_phone":"","info_email":"cashier@example.com","status":"cancelled","by":null,"external_order_id":null,"user":"cashier@example.com","table_id":0,"order_id":null,"payment_method":[{"cash":106}],"burn_points":0,"earn_points":0,"burn_points_amount":0,"available_points":0,"available_points_amount":0,"charges_fee":0,"delivery_charges":0,"phone_number":"","loyalty_mobile_number":"","loyalty_mobile_code":"","crm_delivery_area":null,"invoice_id":null,"done_at":null,"period":null,"is_kds_printed":0,"is_invoice_printed":0,"plate_number":null,"order_no":4,"created_by_user_id":4734,"close_by_user_id":4734,"modified_by_user_ids":[],"is_vat_on_discount_exclude":0,"order_type":"takeaway","channel_id":0,"tip_amount":0,"dining_guests":null,"channel_name":"","is_paid":1,"card_digits":"","crm_user_id":0,"crm_user_name":"","discount_reason":null,"row_discount":0,"paid_at":null,"coupon_amount":0,"coupon_id":0,"coupon_code":null,"printed_invoice_receipts":0,"printed_kds_receipts":0,"app_version":"qa-rd","app_os_version":"qa","build_number":"1","os_type":"qa","round":null,"rider_pickup_at":null,"rider_arrived_at":null,"shift_id":null,"cashier_user_id":null,"cashier_name":null,"shift_clock_in_at":null,"is_order_rebump":1,"order_type_icon_key":"boxicons:takeaway","notes":[],"grand_total":106,"payment_methods":[{"cash":{"amount":106,"tip_amount":null}}],"payments":[{"payment_method":"cash","amount":106,"tip_amount":0}],"items":[{"id":25504102,"identifier":"7c53c3c5-569d-47ae-84b4-7f2e28cbcee6","name":"QA rd 0 Flat White","name_localize":null,"base_price":null,"actual_price":11,"preparation_time":null,"price":11,"qty":1,"is_weightage_item":0,"unit_id":0,"subtotal":11,"line_total":null,"line_vat":null,"line_total_without_vat":null,"invoice_uuid":"3a00fb3a-0c78-444a-abef-3609c849e2e4","product_id":257506,"extras":[{"name":"QA Extra","actual_price":5,"price":5,"quantity":1,"discount_pct":0}],"vat":0,"is_updated":0,"updated_qty":0,"is_addon":0,"is_refunded":0,"refund_quantity":0,"refund_reason":null,"note":null,"addon_linked_with":"7c53c3c5-569d-47ae-84b4-7f2e28cbcee6","discount_pct":10,"discount_amount":1.1,"modifier":[{"name":"QA Modifier","actual_price":3,"price":3,"quantity":1,"discount_pct":0}],"transferred_to":0,"transferred_to_uuid":null,"transferred_quantity":0,"transferred_to_uuid_list":[],"transferred_quantities":[],"transfer_recipients":[],"created_at":1786948030,"updated_at":1786948030,"is_prepared":0,"type":"Espresso-Based Drinks","label_id":50,"item_status":"active","linked_uuid":null,"original_linked_uuid":null,"disposition_type":null,"image":null,"is_combo":0,"is_deleted":null,"deleted_at":null,"deleted_qty":null,"coupon_amount":0,"coupon_id":0,"coupon_code":"","updated_by_user_ids":[],"re":null,"ri":0,"is_wastage":0,"discount_id":null,"discount_name":null,"round":null,"combo_items":null},{"id":25504103,"identifier":"cba24a8b-6e50-4798-a047-05834e9d9d6a","name":"QA addon Classic Sandwich","name_localize":null,"base_price":null,"actual_price":100,"preparation_time":null,"price":95,"qty":1,"is_weightage_item":0,"unit_id":null,"subtotal":95,"line_total":null,"line_vat":null,"line_total_without_vat":null,"invoice_uuid":"3a00fb3a-0c78-444a-abef-3609c849e2e4","product_id":257559,"extras":null,"vat":0,"is_updated":0,"updated_qty":0,"is_addon":1,"is_refunded":0,"refund_quantity":0,"refund_reason":null,"note":null,"addon_linked_with":"7c53c3c5-569d-47ae-84b4-7f2e28cbcee6","discount_pct":0,"discount_amount":0,"modifier":[],"transferred_to":0,"transferred_to_uuid":null,"transferred_quantity":0,"transferred_to_uuid_list":[],"transferred_quantities":[],"transfer_recipients":[],"created_at":1786948030,"updated_at":1786948030,"is_prepared":0,"type":"Alternate Options,Brewed Coffee,Espresso-Based Drinks,Iced & Cold Coffees,Specialty & Flavored Coffees","label_id":null,"item_status":"active","linked_uuid":null,"original_linked_uuid":null,"disposition_type":null,"image":"https://dev1.syspos.ae/api/uploads/","is_combo":0,"is_deleted":null,"deleted_at":null,"deleted_qty":null,"coupon_amount":0,"coupon_id":0,"coupon_code":"","updated_by_user_ids":[],"re":null,"ri":0,"is_wastage":0,"discount_id":null,"discount_name":null,"round":null,"combo_items":null}],"order_type_icon":"https://images.weserv.nl/?url=https%3A%2F%2Fapi.iconify.design%2Fboxicons%2Ftakeaway.svg%3Fwidth%3D128%26height%3D128&output=png"}]}}},"parameters":{"type":"object","properties":{"location_id":{"type":"string","description":"Optional."},"status":{"type":"string","description":"Optional."},"date":{"type":"string","description":"Optional."},"current_page":{"type":"number","description":"Optional."},"page_limit":{"type":"number","description":"Optional."},"invoice_no":{"type":"string","description":"Optional."},"crm_user_id":{"type":"number","description":"Optional."},"crm_user_name":{"type":"string","description":"Optional."},"mobile_number":{"type":"string","description":"Optional."},"phone_number":{"type":"string","description":"Optional."},"guest_name":{"type":"string","description":"Optional."},"plate_number":{"type":"string","description":"Optional."},"brand_id":{"type":"string","description":"Optional."},"shift_id":{"type":"number","description":"Optional."},"user_id":{"type":"number","description":"Optional."},"is_order_rebump":{"type":"string","enum":["1"],"description":"Optional."}},"required":[]},"module":"dine","safety":"read"},{"name":"get_invoice","description":"GET /api/invoice-v2/:uuid. Why: One order header by uuid: status, totals, payments, customer_invoice_id and the branch's receipt header (business_info). web-pos never calls it (it backs the public receipt page). Use it when you only hold a uuid; for the full order (lines, customer, is_paid, order_type) call list_orders_history with invoice_no = customer_invoice_id. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_invoice\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/invoice-v2/:uuid","why":"One order header by uuid: status, totals, payments, customer_invoice_id and the branch's receipt header (business_info). web-pos never calls it (it backs the public receipt page). Use it when you only hold a uuid; for the full order (lines, customer, is_paid, order_type) call list_orders_history with invoice_no = customer_invoice_id.","response":{"description":"A BARE ARRAY with one invoice object (no status envelope): header columns + business_info (receipt settings of the branch) + payments[]. Unknown uuid → HTTP 400 with []; database error → HTTP 500 { msg }.","fields":{"[]":"array. Always ONE element (the invoice). Unknown uuid → HTTP 400 with [] (the tool returns an error).","[].logo_image":"string | null. Tenant logo file name (not a URL).","[].id":"int. DATABASE id of the invoice (legacy invoice_id when set). NOT the invoice number — use customer_invoice_id.","[].items_number":"int. Number of lines when saved.","[].subtotal":"number. Total before the order discount.","[].vat":"number. Tax (out of scope).","[].payable":"number. Grand total.","[].discountamount":"number. Order discount amount.","[].discountpct":"number. Order discount %.","[].paid":"number. Amount tendered.","[].change":"number. Change given.","[].note":"string | null. Order note (\"<reason> **canceled by: <user>\" after a cancel).","[].uuid":"string. Order key (same as list_orders_history uuid).","[].created_at":"string. ISO date-time in UTC (not unix seconds as in list_orders_history).","[].customer_id":"int. Tenant id.","[].location_id":"int. Branch id.","[].info_name":"string | null. Name sent with the order.","[].info_address":"string | null. Address sent with the order.","[].info_phone":"string | null. Phone sent with the order.","[].info_email":"string | null. E-mail sent with the order.","[].status":"string. Same values as list_orders_history status.","[].user":"string. Login of the POS user whose session saved it.","[].table_id":"int | null. Table (list_selling_tables). 0 = none.","[].plate_number":"string | null. Car plate.","[].customer_invoice_id":"int. INVOICE NUMBER. Pass it as invoice_no to list_orders_history for the full order (items, customer, payments with tips).","[].business_info":"object. Receipt header settings of the order's branch. A key is missing when it is not configured.","[].business_info.time_zone":"string. Branch UTC offset, e.g. \"+05:00\".","[].business_info.vat":"string. Tax % setting (out of scope).","[].business_info.info_name":"string. Business name for the receipt.","[].business_info.info_address":"string. Business address.","[].business_info.info_email":"string. Business e-mail.","[].business_info.info_phone":"string. Business phone.","[].business_info.info_vat_no":"string. Tax registration number (out of scope).","[].business_info.logo_image":"string. Logo file name.","[].business_info.opening_hour":"string. Business-day start \"HH:mm\".","[].business_info.closing_hour":"string. Business-day end \"HH:mm\" (can be after midnight).","[].business_info.currency":"string. Currency label (\"\" when not set).","[].business_info.receipt_footer_text":"string. HTML footer of the receipt.","[].business_info.exchange_rate":"string. USD exchange rate setting.","[].business_info.max_order_time":"string. Minutes; kitchen time target.","[].business_info.is_printer_mpop":"string. Printer setting. NOT USED.","[].business_info.is_done_confirmation":"string. KDS setting. NOT USED.","[].business_info.is_plate_number":"string. \"1\" = plate number asked on orders.","[].business_info.decimal_points":"string. Decimals to show on amounts.","[].business_info.Product code types":"string. Barcode setting. NOT USED.","[].payments":"array. Payment rows of the order, oldest first. [] = none.","[].payments[].payment_method":"string. Method name as saved (list_selling_payment_methods name, any case).","[].payments[].amount":"number. Amount; negative = refund row."},"master_rules":["web-pos never calls this route. It backs the public receipt page: the QR code web-pos prints points to app.syspos.ae/#/download-invoice/invoice/<uuid>.","Use it when you only have a uuid (e.g. from list_dine_orders, get_invoice_activities or a cancel/refund result): it gives status, totals, payments and customer_invoice_id. For the full order call list_orders_history with invoice_no = customer_invoice_id.","It reads the invoice by uuid only — no branch/date filter and no is_paid, order_type, customer or line fields."],"example":[{"logo_image":"1762167059218-Nexus_logo_2015.svg.png","id":8085851,"items_number":2,"subtotal":106,"vat":0,"payable":106,"discountamount":0,"discountpct":0,"paid":106,"change":0,"note":"qa cancel **canceled by: Nexus Cafe","uuid":"3a00fb3a-0c78-444a-abef-3609c849e2e4","created_at":"2026-08-17T06:27:10.000Z","customer_id":437,"location_id":907,"info_name":"QA","info_address":"","info_phone":"","info_email":"cashier@example.com","status":"cancelled","user":"cashier@example.com","table_id":0,"plate_number":null,"customer_invoice_id":19,"business_info":{"time_zone":"+05:00","vat":"5","info_name":"Nexus","info_address":"Nexus located at Dev","info_email":"info@example.com","info_vat_no":"1423","logo_image":"1762167059218-Nexus_logo_2015.svg.png","opening_hour":"10:00","closing_hour":"08:00","currency":"","info_phone":"500000000","receipt_footer_text":"<ul><li>Optional note</li></ul>","exchange_rate":"12","max_order_time":"20"},"payments":[{"payment_method":"cash","amount":106}]}]},"parameters":{"type":"object","properties":{"uuid":{"type":"string","description":"Required."}},"required":["uuid"]},"module":"dine","safety":"read"},{"name":"get_invoice_items","description":"GET /api/invoice-v2/:uuid/items. Why: Raw database lines of one order by uuid (every invoice_items column). web-pos never calls it. Prefer list_orders_history items[] (parsed, correct extras, nested combos); use this only for database-only columns such as refund_pm_type, refund_amount, item_price_after_discount or cost snapshots. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_invoice_items\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/invoice-v2/:uuid/items","why":"Raw database lines of one order by uuid (every invoice_items column). web-pos never calls it. Prefer list_orders_history items[] (parsed, correct extras, nested combos); use this only for database-only columns such as refund_pm_type, refund_amount, item_price_after_discount or cost snapshots.","response":{"description":"A BARE ARRAY of line rows (SELECT * FROM invoice_items), add-on rows excluded (nested in addon_linked_with). modifier / updated_by_user_ids / transfer lists are RAW JSON text; dates are ISO strings. Error: HTTP 500 { error }.","fields":{"[]":"array. Lines of the order from the database (SELECT *), in no set order. [] = unknown uuid or no lines.","[].id":"int. Line id. Same as list_orders_history items[].id.","[].name":"string. Product name as sold.","[].price":"number. Unit price as saved.","[].qty":"number. Quantity (lowered by a partial refund).","[].subtotal":"number. Line total as saved.","[].invoice_uuid":"string. Order uuid.","[].customer_id":"int. Tenant id.","[].location_id":"int. Branch id.","[].product_id":"int. Product (list_selling_products).","[].extras":"array. Rebuilt as [{ name, qty }] from a { materialId: qty } map. For master tenants (extras saved as priced lines) it comes back [] — read extras from list_orders_history instead.","[].vat":"number. Tax (out of scope).","[].vat_pct":"number | null. Tax % (out of scope).","[].vat_name":"string | null. Tax name (out of scope).","[].is_addon":"0 | null. Always 0/null here: add-on rows are only returned inside addon_linked_with.","[].note":"string | null. Line note.","[].is_refunded":"0 | 1. 1 = line (partly) refunded.","[].refund_quantity":"int. Refunded quantity.","[].refund_reason":"string | null. Refund reason.","[].is_updated":"0 | 1. Quantity changed after the first save.","[].updated_qty":"int. The changed quantity.","[].addon_linked_with":"array | string | null. REPLACED by the server: when set, the add-on rows of this line [{ id, name, actual_price, vat, price, subtotal, qty }] ([] = none). null = no add-on link.","[].addon_linked_with[].id":"int. Add-on row id.","[].addon_linked_with[].name":"string. Add-on name.","[].addon_linked_with[].actual_price":"number. Add-on unit price.","[].addon_linked_with[].vat":"number. Tax (out of scope).","[].addon_linked_with[].price":"number. Price as saved.","[].addon_linked_with[].subtotal":"number. Add-on row total.","[].addon_linked_with[].qty":"number. Add-on quantity.","[].discount_pct":"number | null. Line discount %.","[].refund_pm_type":"string. Payment method used for the refund of this line (default \"cash\").","[].modifier":"string | null. RAW JSON TEXT of the chosen options — parse it (same objects as list_orders_history items[].modifier).","[].transferred_to":"int. Invoice number (customer_invoice_id) the line was moved to; 0 = none.","[].transferred_to_uuid":"string | null. uuid of that order.","[].created_at":"string. ISO date-time (UTC).","[].updated_at":"string. ISO date-time (UTC).","[].combo_item_id":"int | null. Set = this row is a CHILD of the combo line with that id (combo children are returned as separate rows here).","[].is_combo":"0 | 1 | 2 | null. 1/2 = combo line.","[].product_type":"string | null. NOT USED.","[].is_internal":"0 | 1 | null. NOT USED.","[].sort_order":"number | null. NOT USED.","[].plu":"string | null. NOT USED. Aggregator PLU.","[].otter_account_id":"string | null. NOT USED.","[].delivery_cost":"string | null. NOT USED.","[].tip":"number | null. NOT USED.","[].by":"string | null. NOT USED.","[].otter_id":"string | null. NOT USED.","[].transferred_quantity":"number. Quantity moved.","[].actual_price":"number. Unit price of the product alone (VAT included).","[].is_prepared":"0 | 1 | null. Prepared on the KDS.","[].discount_amount":"number. Line discount per unit.","[].base_price":"number | null. NOT USED.","[].brand_id":"int. Brand of the line (0 when not set).","[].is_deleted":"0 | 1 | null. 1 = removed from the order (not billed). Removed rows ARE returned here.","[].deleted_qty":"number | null. Quantity removed.","[].deleted_at":"string | null. ISO date-time of the removal.","[].label_id":"int | null. Kitchen label (list_labels).","[].preparation_time":"int | null. Minutes.","[].linked_uuid":"string | null. Linked order uuid (transfer/split).","[].item_type":"string | null. NOT USED.","[].item_price_after_discount":"number | null. Unit price after the line discount.","[].line_vat":"number | null. Tax (out of scope).","[].line_total":"number | null. NOT USED (null in most data).","[].line_total_without_vat":"number | null. NOT USED.","[].identifier":"string. Line uuid.","[].coupon_amount":"number. Coupon amount on the line.","[].coupon_id":"int. Coupon (list_selling_coupons). 0 = none.","[].coupon_code":"string | null. Coupon code.","[].product_recipe_cost":"number. Recipe cost snapshot per unit.","[].total_cost":"number | null. Cost snapshot of the line.","[].updated_by_user_ids":"string | null. RAW text list of POS user ids who changed the line.","[].re":"string | null. Cancel/return reason.","[].ri":"0 | 1. 1 = stock returned on cancel.","[].is_wastage":"0 | 1. 1 = written off as wastage.","[].discount_id":"int | null. Discount rule (list_discounts).","[].discount_name":"string | null. Discount name.","[].round":"int | null. Dine-in round.","[].name_localize":"string | null. Localized name.","[].original_linked_uuid":"string | null. First linked order uuid.","[].item_status":"string. \"active\" or \"disposition\".","[].disposition_type":"string | null. Disposition kind.","[].disposition_reason":"string | null. Disposition reason.","[].refund_amount":"number | null. Refunded amount of the line.","[].modifiers_cost_amount":"number | null. Cost snapshot of the options.","[].cost_snapshot_at":"string | null. ISO date-time of the cost snapshot.","[].transferred_to_uuid_list":"string | null. RAW JSON text of target order uuids.","[].transferred_quantities":"string | null. RAW JSON text of quantities per transfer.","[].transfer_recipients":"string | null. RAW JSON text of recipient ids."},"master_rules":["web-pos never calls this route (dashboard / receipt page). Prefer list_orders_history (invoice_no = customer_invoice_id): its items[] are parsed, add-ons are rows, combos are nested and extras are correct.","Use this tool only for database-only columns: refund_pm_type, refund_amount, item_price_after_discount, vat_pct, cost snapshots, disposition_reason, combo_item_id.","Rows: every line except add-on rows (those are nested in addon_linked_with). Removed lines (is_deleted = 1) and combo children (combo_item_id set) come back as normal rows — filter them yourself.","Links: product_id → list_selling_products; coupon_id → list_selling_coupons; transferred_to → another order's customer_invoice_id."],"example":[{"id":25504102,"name":"QA rd 0 Flat White","price":11,"qty":1,"subtotal":11,"invoice_uuid":"3a00fb3a-0c78-444a-abef-3609c849e2e4","customer_id":437,"location_id":907,"product_id":257506,"extras":[],"vat":0,"vat_pct":null,"vat_name":null,"is_addon":0,"note":null,"is_refunded":0,"refund_quantity":0,"refund_reason":null,"is_updated":0,"updated_qty":0,"addon_linked_with":[{"id":25504103,"name":"QA addon Classic Sandwich","actual_price":100,"vat":0,"price":95,"subtotal":95,"qty":1}],"discount_pct":10,"refund_pm_type":"cash","modifier":"[{\"name\":\"QA Modifier\",\"actual_price\":3,\"price\":3,\"quantity\":1,\"discount_pct\":0}]","transferred_to":0,"transferred_to_uuid":null,"created_at":"2026-08-17T06:27:10.000Z","updated_at":"2026-08-17T06:27:10.000Z","combo_item_id":null,"is_combo":0,"product_type":null,"is_internal":null,"sort_order":null,"plu":null,"otter_account_id":null,"delivery_cost":null,"tip":null,"by":null,"otter_id":null,"transferred_quantity":0,"actual_price":11,"is_prepared":0,"discount_amount":1.1,"base_price":null,"brand_id":0,"is_deleted":null,"deleted_qty":null,"deleted_at":null,"label_id":50,"preparation_time":null,"linked_uuid":null,"item_type":null,"item_price_after_discount":9.9,"line_vat":null,"line_total":null,"line_total_without_vat":null,"identifier":"7c53c3c5-569d-47ae-84b4-7f2e28cbcee6","coupon_amount":0,"coupon_id":0,"coupon_code":"","product_recipe_cost":0,"total_cost":0,"updated_by_user_ids":"","re":null,"ri":0,"is_wastage":0,"discount_id":null,"discount_name":null,"round":null,"name_localize":null,"original_linked_uuid":null,"item_status":"active","disposition_type":null,"disposition_reason":null,"refund_amount":null,"modifiers_cost_amount":0,"cost_snapshot_at":"2026-08-17T06:27:10.000Z","transferred_to_uuid_list":null,"transferred_quantities":null,"transfer_recipients":null}]},"parameters":{"type":"object","properties":{"uuid":{"type":"string","description":"Required."}},"required":["uuid"]},"module":"dine","safety":"read"},{"name":"list_materials","description":"GET /api/materials. Why: Material (inventory SKU) catalog of the master inventory: raw materials (material_type mat) and recipe-output materials (rm) with branch stock, branch unit cost (avg_price), storage/ingredient units, category, suppliers and brands — the dashboard Inventory → Materials list. Use it to find material ids for get_material / update_material / delete_material, recipe ingredients (list_recipes / get_recipe), supplier catalogs and draft purchase orders (list_supplier_materials, create_draft_purchase_order), wastage, counts, transfers and work orders. Stock here is the branch on-hand qty; the movements behind it are in get_material_movements / list_inventory_movements, the ledger sum in get_material_stock. Filter by category with `cat` (id from list_material_categories), branch with `loc` (list_locations). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_materials\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/materials","why":"Material (inventory SKU) catalog of the master inventory: raw materials (material_type mat) and recipe-output materials (rm) with branch stock, branch unit cost (avg_price), storage/ingredient units, category, suppliers and brands — the dashboard Inventory → Materials list. Use it to find material ids for get_material / update_material / delete_material, recipe ingredients (list_recipes / get_recipe), supplier catalogs and draft purchase orders (list_supplier_materials, create_draft_purchase_order), wastage, counts, transfers and work orders. Stock here is the branch on-hand qty; the movements behind it are in get_material_movements / list_inventory_movements, the ledger sum in get_material_stock. Filter by category with `cat` (id from list_material_categories), branch with `loc` (list_locations).","response":{"description":"200 → { data, pagination }. data = one object per material: materials columns plus the loc-scoped stock/avg_price, barcode (sku_inventory), storageunit, tax_name/tax_value, location_id (branch list), suppliers, suppliers_prices and brand_ids. Filters always applied: tenant = session, is_active = is_active (default 1). Order: id DESC. No match → { data: [], pagination: { total: 0, … } }. Errors → 500 { error: \"Failed to fetch materials\", detail }.","fields":{"data":"array. Materials on this page (whole list with MCP's default _limit 10000), newest id first.","data[].id":"int. Material id. Pass as `id` to get_material / update_material / delete_material / get_material_used_in_recipes, as `material_id` to get_material_stock / get_material_movements, and as the ingredient id in create_recipe / purchase-order / wastage / count / transfer lines.","data[].name":"string. Material name (unique among ACTIVE materials of the tenant, case-insensitive).","data[].description":"string | null. Free text. Recipe-output materials carry \"Recipe: <recipe name>\" (update_material then defaults material_type to rm).","data[].price":"number | null. Material cost price (materials.price). Fixed costing: the default cost pushed to branches. Average costing: 0 / legacy — real cost is avg_price.","data[].selling_price":"number | null. Retail price when the material itself is sold (dashboard \"Retail Price\"/\"Selling\" column). 0 = not set.","data[].wholesale_price":"number | null. Wholesale price (0/null = not set).","data[].stock":"number | null. On-hand qty in the STORAGE unit (`unit`) for the loc scope: one branch → that branch's materials_locations.stock (null/0 when no row); several/all branches → SUM. Can be negative (sales deduct past zero). Movements behind it: get_material_movements.","data[].unit":"string | null. Storage (purchase/stock) unit label, e.g. \"Kilogram\". stock, low_stock, min/max/par and avg_price are per this unit.","data[].storageunit":"string. Display label of the storage unit (units_of_measure.uom_name, else the customer-UOM label, else unit). Same meaning as unit.","data[].unit_id":"int | null. Standard storage unit id (units_of_measure). Exactly one of unit_id / custom_unit_id is set.","data[].custom_unit_id":"int | null. Customer-defined storage unit id (customer_uom_conversions, e.g. \"Case of 12\"). null when unit_id is used.","data[].ingredient_unit_id":"int | null. Standard id of the ingredient (recipe) unit. Informational; create/update never write it from the dashboard.","data[].ingredientunit":"string. Ingredient (recipe) unit label, e.g. \"Gram\"; falls back to unit. Recipe quantities of this material are in this unit.","data[].conversionrate":"string (numeric). Ingredient units per 1 storage unit (e.g. \"1000\" for Kilogram → Gram, \"1\" when both are the same). Recipe line cost = qty × avg_price / conversionrate ÷ (yield/100).","data[].avg_price":"number. Unit cost per storage unit for the loc scope: branch avg_cost (for fixed costing a 0 branch cost is skipped) → else materials.avg_price → else price → else 0. Several/all branches → average of the branches' costs. Dashboard \"Cost\" column.","data[].costingmethod":"string. Costing method: \"average\" (cost moves with purchases/receipts) or \"fixed\" (cost = typed price, per-branch override via location_prices). Legacy rows may be capitalised (\"Fixed\") or blank (= average) — compare case-insensitively.","data[].customer_id":"int. Tenant id. Always your own; not needed.","data[].location_id":"int | int[]. Branches the material is stocked at (materials_locations rows): a bare int when exactly one, else an array ([] = on no branch). Send this full list back as location_id on update_material.","data[].low_stock":"number | null. Low-stock alert threshold in the storage unit (0 = no alert). The dashboard marks a row low when stock <= low_stock; it has no field to edit it (import/API only).","data[].physical_stock":"number | null. Legacy column, usually null. Not needed.","data[].actual_stock":"number. Legacy column, usually 0. Not live stock; not needed.","data[].is_active":"0 | 1. 1 = active. 0 = deactivated by delete_material (it then has no branch rows).","data[].barcode":"string. Barcode (latest sku_inventory barcode, else materials.barcode; \"\" = none).","data[].sku_no":"string | null. Material SKU, \"mat-<customer_id>-<id>\" for materials created through the API; null/\"\" on older rows (dev1 returns null where the newer build returns \"\").","data[].minimum_level":"string (numeric). Minimum stock level (storage unit), \"0\" = not set. Dashboard Stock Levels section.","data[].maximum_level":"string (numeric). Maximum stock level, \"0\" = not set.","data[].par_level":"string (numeric). Par (target) level used for re-order suggestions, \"0\" = not set.","data[].type":"string. Legacy free-text kind: \"raw\" (normal materials), \"prepared\" (recipe outputs) or an old category name. Not the category — use category_id. list_materials `cat` with text filters on it.","data[].category_id":"int | null. Material category id (list_material_categories / get_material_category); null = uncategorised.","data[].material_type":"\"mat\" | \"rm\". mat = raw/purchased material; rm = material produced by a recipe (linked_recipe).","data[].mode":"\"stockable\" | \"phantom\". stockable = keeps its own stock; phantom = recipe output that is never stocked (its ingredients are deducted instead). Materials created via create_material are always stockable.","data[].linked_recipe":"int | null. For rm materials: the recipe (get_recipe) that produces this material. null for raw materials.","data[].tax_id":"int | null. Tax id — tax groups are out of scope; resend unchanged on update_material (omitting it clears the tax).","data[].tax_name":"string | null. Name of tax_id. Out of scope.","data[].tax_value":"number | null. Rate of tax_id. Out of scope.","data[].suppliers":"int[]. Supplier ids linked to the material (list_suppliers / get_supplier). [] = none.","data[].suppliers_prices":"array. Per-supplier purchase price rows ([] = none).","data[].suppliers_prices[].supplier_id":"int. Supplier id.","data[].suppliers_prices[].purchase_price":"number. Purchase price per storage unit from that supplier (default for create_draft_purchase_order lines).","data[].is_prepared":"0 | 1. 1 = produced in-house (recipe/preparation output), 0 = purchased.","data[].linked_sku":"string | null. SKU of linked_recipe (\"rcp-<customer_id>-<recipe id>\"). null for raw materials.","data[].yield_percentage":"number. Usable yield % after prep (100 = no loss). Recipe cost divides by it unless the recipe line has its own yield.","data[].materials":"null. Legacy FLOAT column, always null. Not needed.","data[].sale_price":"number. Legacy retail price column (0). Use selling_price.","data[].created_at":"string (ISO timestamp). Created at.","data[].updated_at":"string (ISO timestamp). Last update.","data[].brand_ids":"int[]. Brand ids the material is limited to (material_brands; [] = all brands).","pagination":"object. Paging info.","pagination.total":"int. Materials matching all filters (all pages).","pagination.page":"int. Page returned (_page).","pagination.limit":"int. Page size used (_limit).","pagination.totalPages":"int. ceil(total / limit).","error":"string. Error only (500): \"Failed to fetch materials\".","detail":"string. Error only (500): database error text."},"example_request":{"loc":"907","cat":"2","_limit":20},"example":{"data":[{"id":119158,"name":"Cherry Blosom","description":"Cherry","price":0,"stock":-12762188.785166,"unit":"Kilogram","customer_id":437,"location_id":[907,908,909,910,922],"low_stock":0,"physical_stock":null,"is_active":1,"barcode":"","ingredientunit":"Gram","storageunit":"Kilogram","is_prepared":0,"conversionrate":"1000","minimum_level":"0","maximum_level":"0","par_level":"0","costingmethod":"average","actual_stock":0,"type":"raw","avg_price":1.6474,"materials":null,"sale_price":0,"selling_price":0,"created_at":"2026-02-09T07:05:18.000Z","updated_at":"2026-08-05T07:03:56.000Z","wholesale_price":0,"tax_id":null,"material_type":"mat","linked_recipe":null,"linked_sku":null,"mode":"stockable","unit_id":2,"ingredient_unit_id":1,"custom_unit_id":null,"category_id":2,"yield_percentage":100,"sku_no":null,"tax_name":null,"tax_value":null,"suppliers":[970],"suppliers_prices":[{"supplier_id":970,"purchase_price":1.1092}],"brand_ids":[412,473]}],"pagination":{"total":1,"page":1,"limit":20,"totalPages":1}},"master_rules":["The dashboard Materials page (Inventory → Materials) calls this with _page, _limit 10 (5/10/25/50), is_active \"1\" (Status filter; \"0\" = Inactive), material_type \"mat\" (Type filter: Materials default, Recipes → \"rm\", Both → omitted), loc (first open: the user's branch, else the first branch; multi-select → csv) and when set: k (search), cat (category id), brand_id. It also sends adm and type, which the server ignores. To see what a user sees send the same params; for the whole list omit _limit.","Each row the dashboard shows: name (title-cased) with sku_no (fallback \"mat-<id>\"), Type = type (else material_type), \"<n> Locations\" from location_id, Unit = unit (else storageunit/ingredientunit), Cost = avg_price (else price), Selling = selling_price, Stock = stock (red when low_stock > 0 and stock <= low_stock), Status from is_active. The four header cards come from get_materials_stats, not from this list.","Use exactly ONE loc to get branch stock/cost and the full column set; with several or all branches stock is summed, avg_price averaged and category_id/unit_id/linked_recipe/yield_percentage are missing.","Filter by category with cat=<category id>, never category_id (ignored). A parent category matches only materials assigned to the parent itself.","Recipes: rm rows are recipe outputs (linked_recipe → get_recipe); ingredients of recipes are material ids from this list, quantities in the material's ingredientunit (qty / conversionrate = storage units). Products and modifier items consume materials through recipes/BOM; every sale (sync_invoice) deducts branch stock, which is why stock can go negative.","Buying: suppliers / suppliers_prices give the default supplier and price for create_draft_purchase_order; list_supplier_materials is the supplier-side catalog. Stock changes only through purchase/transfer/count/wastage/work-order flows — never through update_material.","Before advising deactivation call get_material_used_in_recipes; before quoting stock history call get_material_movements."],"caveats":["dev1 (Sep 2026) returns sku_no null for most rows even when get_material returns \"\" or an SKU; treat null and \"\" as \"no SKU\".","costingmethod values are mixed case (\"Fixed\", \"fixed\", \"average\"); conversionrate/minimum_level/maximum_level/par_level are strings.","stock can be hugely negative on test tenants (e.g. -12762188.79) because sales deduct without a stock check.","brand_id filter keeps only materials with an explicit brand link; materials available to all brands (brand_ids []) are dropped."]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id or comma-separated ids (list_locations)."},"k":{"type":"string","description":"Case-insensitive substring of the material name (LIKE %k%; % and _ act as wildcards)."},"cat":{"type":"string","description":"Category filter."},"material_type":{"type":"string","enum":["mat","rm"],"description":"\"mat\" = raw/purchased materials, \"rm\" = recipe-output materials."},"mode":{"type":"string","enum":["stockable","phantom"],"description":"\"stockable\" or \"phantom\" (exact materials.mode)."},"is_prepared":{"type":"string","enum":["0","1"],"description":"\"1\" = produced in-house (recipe/preparation outputs), \"0\" = purchased (exact match)."},"is_active":{"type":"string","enum":["0","1"],"description":"\"1\" → active materials (default when omitted), \"0\" → deactivated ones (delete_material)."},"brand_id":{"type":"number","description":"Brand id (list_brands): only materials explicitly limited to that brand (material_brands)."},"_page":{"type":"number","description":"1-based page."},"_limit":{"type":"number","description":"Page size."}},"required":[]},"module":"materials","safety":"read"},{"name":"get_material","description":"GET /api/materials/:id. Why: One material with its stock, reservations and unit cost at one branch (`loc`), units, costing method, suppliers with purchase prices, brands and branch list — the dashboard material edit/detail page. Load it before update_material (the update overwrites every field, so you pass this record back) and to read reserved/available stock before a transfer, wastage or count. Per-branch stock/cost: call once per branch (loc). Recipe outputs (material_type rm) link to their recipe via linked_recipe (get_recipe). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_material\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/materials/:id","why":"One material with its stock, reservations and unit cost at one branch (`loc`), units, costing method, suppliers with purchase prices, brands and branch list — the dashboard material edit/detail page. Load it before update_material (the update overwrites every field, so you pass this record back) and to read reserved/available stock before a transfer, wastage or count. Per-branch stock/cost: call once per branch (loc). Recipe outputs (material_type rm) link to their recipe via linked_recipe (get_recipe).","response":{"description":"200 → [material]: id, prices, branch stock/reservations/avg_price for loc, units, costing, category, type/mode, linked_recipe, tax (out of scope), location_id (all branches of the material), suppliers, suppliers_prices, brands. The newer build also returns yield_percentage and location_prices[] (every branch's stock/cost in one call); dev1 (Sep 2026) does not. Errors → 400 { error: \"Invalid material ID\" }, 404 { error: \"Material not found\" }, 500 { error: \"Database error\", detail }.","fields":{"[]":"array. Always exactly one element (the material).","[].id":"int. Material id. Pass as `id` to get_material / update_material / delete_material / get_material_used_in_recipes, as `material_id` to get_material_stock / get_material_movements, and as the ingredient id in create_recipe / purchase-order / wastage / count / transfer lines.","[].name":"string. Material name (unique among ACTIVE materials of the tenant, case-insensitive).","[].description":"string | null. Free text. Recipe-output materials carry \"Recipe: <recipe name>\" (update_material then defaults material_type to rm).","[].price":"number | null. Material cost price (materials.price). Fixed costing: the default cost pushed to branches. Average costing: 0 / legacy — real cost is avg_price.","[].selling_price":"number | null. Retail price when the material itself is sold (dashboard \"Retail Price\"/\"Selling\" column). 0 = not set.","[].wholesale_price":"number | null. Wholesale price (0/null = not set).","[].stock":"number. On-hand qty at the `loc` branch in the STORAGE unit (materials_locations.stock; 0 when the material is not on that branch). Can be negative: sales/recipes deduct past zero.","[].unit":"string | null. Storage (purchase/stock) unit label, e.g. \"Kilogram\". stock, low_stock, min/max/par and avg_price are per this unit.","[].storageunit":"string. Display label of the storage unit (units_of_measure.uom_name, else the customer-UOM label, else unit). Same meaning as unit.","[].unit_id":"int | null. Standard storage unit id (units_of_measure). Exactly one of unit_id / custom_unit_id is set.","[].custom_unit_id":"int | null. Customer-defined storage unit id (customer_uom_conversions, e.g. \"Case of 12\"). null when unit_id is used.","[].ingredient_unit_id":"int | null. Standard id of the ingredient (recipe) unit. Informational; create/update never write it from the dashboard.","[].ingredientunit":"string. Ingredient (recipe) unit label, e.g. \"Gram\"; falls back to unit. Recipe quantities of this material are in this unit.","[].conversionrate":"string (numeric). Ingredient units per 1 storage unit (e.g. \"1000\" for Kilogram → Gram, \"1\" when both are the same). Recipe line cost = qty × avg_price / conversionrate ÷ (yield/100).","[].avg_price":"number | null. Unit cost (per storage unit) at the `loc` branch = materials_locations.avg_cost, no fallback (null when the material is not on that branch).","[].costingmethod":"string. Costing method: \"average\" (cost moves with purchases/receipts) or \"fixed\" (cost = typed price, per-branch override via location_prices). Legacy rows may be capitalised (\"Fixed\") or blank (= average) — compare case-insensitively.","[].customer_id":"int. Tenant id. Always your own; not needed.","[].location_id":"int | int[]. Branches the material is stocked at (materials_locations rows): a bare int when exactly one, else an array ([] = on no branch). Send this full list back as location_id on update_material.","[].low_stock":"number | null. Low-stock alert threshold in the storage unit (0 = no alert). The dashboard marks a row low when stock <= low_stock; it has no field to edit it (import/API only).","[].physical_stock":"number | null. Legacy column, usually null. Not needed.","[].actual_stock":"number. Legacy column, usually 0. Not live stock; not needed.","[].is_active":"0 | 1. 1 = active. 0 = deactivated by delete_material (it then has no branch rows).","[].barcode":"string. Barcode (latest sku_inventory barcode, else materials.barcode; \"\" = none).","[].sku_no":"string | null. Material SKU, \"mat-<customer_id>-<id>\" for materials created through the API; null/\"\" on older rows (dev1 returns null where the newer build returns \"\").","[].minimum_level":"string (numeric). Minimum stock level (storage unit), \"0\" = not set. Dashboard Stock Levels section.","[].maximum_level":"string (numeric). Maximum stock level, \"0\" = not set.","[].par_level":"string (numeric). Par (target) level used for re-order suggestions, \"0\" = not set.","[].type":"string. Legacy free-text kind: \"raw\" (normal materials), \"prepared\" (recipe outputs) or an old category name. Not the category — use category_id. list_materials `cat` with text filters on it.","[].category_id":"int | null. Material category id (list_material_categories / get_material_category); null = uncategorised.","[].material_type":"\"mat\" | \"rm\". mat = raw/purchased material; rm = material produced by a recipe (linked_recipe).","[].mode":"\"stockable\" | \"phantom\". stockable = keeps its own stock; phantom = recipe output that is never stocked (its ingredients are deducted instead). Materials created via create_material are always stockable.","[].linked_recipe":"int | null. For rm materials: the recipe (get_recipe) that produces this material. null for raw materials.","[].tax_id":"int | null. Tax id — tax groups are out of scope; resend unchanged on update_material (omitting it clears the tax).","[].tax_name":"string | null. Name of tax_id. Out of scope.","[].tax_value":"number | null. Rate of tax_id. Out of scope.","[].suppliers":"int[]. Supplier ids linked to the material (list_suppliers / get_supplier). [] = none.","[].suppliers_prices":"array. Per-supplier purchase price rows ([] = none).","[].suppliers_prices[].supplier_id":"int. Supplier id.","[].suppliers_prices[].purchase_price":"number. Purchase price per storage unit from that supplier (default for create_draft_purchase_order lines).","[].reserved_stock":"number. Qty reserved at the branch (e.g. by open orders). 0 when none.","[].committed_stock":"number. Qty committed at the branch (does not reduce available_for_sale). 0 when none.","[].available_for_sale":"number. stock − reserved_stock at the branch.","[].yield_percentage":"number. Usable yield % after prep (100 = no loss). Newer build only — dev1 (Sep 2026) omits it here; list_materials with one loc returns it.","[].location_prices":"array. One row per branch the material is on (materials_locations). Newer build only — absent on dev1 (Sep 2026); call get_material once per loc instead.","[].location_prices[].location_id":"int. Branch id (list_locations).","[].location_prices[].avg_cost":"number | null. That branch's unit cost.","[].location_prices[].stock":"number. That branch's on-hand qty.","[].location_prices[].reserved_stock":"number. That branch's reserved qty.","[].location_prices[].committed_stock":"number. That branch's committed qty.","[].location_prices[].available_for_sale":"number. stock − reserved_stock at that branch.","[].brands":"array. Brands the material is limited to ([] = all brands). On update_material send brand_ids = brands[].id.","[].brands[].id":"int. Brand id (list_brands).","[].brands[].name":"string. Brand name.","error":"string. Error only: \"Invalid material ID\" (400, non-numeric id), \"Material not found\" (404, unknown id or another tenant's), \"Database error\" (500).","detail":"string. Error only (500): database error text."},"example_request":{"id":119158,"loc":907},"example":[{"id":119158,"name":"Cherry Blosom","description":"Cherry","price":0,"selling_price":0,"wholesale_price":0,"stock":-12762188.785166,"unit":"Kilogram","unit_id":2,"custom_unit_id":null,"ingredient_unit_id":1,"avg_price":1.6474,"customer_id":437,"location_id":[907,908,909,910,922],"low_stock":0,"physical_stock":null,"actual_stock":0,"is_active":1,"barcode":"","sku_no":"","conversionrate":"1000","ingredientunit":"Gram","storageunit":"Kilogram","costingmethod":"average","minimum_level":"0","maximum_level":"0","par_level":"0","type":"raw","category_id":2,"material_type":"mat","mode":"stockable","linked_recipe":null,"tax_id":null,"tax_name":null,"tax_value":null,"suppliers":[970],"suppliers_prices":[{"supplier_id":970,"purchase_price":1.1092}],"reserved_stock":0,"committed_stock":0,"available_for_sale":-12762188.785166,"brands":[{"id":412,"name":"Nexus Cafe"},{"id":473,"name":"Test "}]}],"master_rules":["The dashboard edit page (Inventory → Materials → Edit) loads get_material {id, loc} and saves with update_material = this whole object + the edited form fields + id; do the same (see update_material). The detail page also calls it once per selected branch to build its per-branch cost/stock table (avg_price, stock) and shows reserved_stock and available_for_sale = stock − reserved_stock.","location_id is the full branch list regardless of loc; stock/avg_price are for loc only. To compare branches call once per branch (or read location_prices on builds that return it).","avg_price here is the raw branch cost (null when the material is not on that branch) — unlike list_materials there is no fallback to price.","brands comes back as objects; update_material needs brand_ids = brands[].id.","rm materials: linked_recipe → get_recipe (ingredients, yield, mode). For any material, get_material_used_in_recipes lists the recipes consuming it."],"caveats":["dev1 (Sep 2026) omits yield_percentage and location_prices; list_materials with a single loc returns yield_percentage.","A csv loc silently falls back to the API user's own branch."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Material id (list_materials)."},"loc":{"type":"number","description":"ONE branch id (list_locations)."}},"required":["id"]},"module":"materials","safety":"read"},{"name":"get_material_stock","description":"GET /api/inventory/:material_id/stock. Why: Ledger check: the sum of every inventory movement qty of one material at one branch (storage unit). Use it to cross-check the branch stock shown by get_material / list_materials (materials_locations) — they can drift — or when only the movement ledger counts. For history use get_material_movements; for all materials list_inventory / list_materials. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_material_stock\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/inventory/:material_id/stock","why":"Ledger check: the sum of every inventory movement qty of one material at one branch (storage unit). Use it to cross-check the branch stock shown by get_material / list_materials (materials_locations) — they can drift — or when only the movement ledger counts. For history use get_material_movements; for all materials list_inventory / list_materials.","response":{"description":"200 → [{ stock }] (one element). No error handling: a SQL error (non-numeric id or location) is not caught and restarts the API.","fields":{"[]":"array. Always one element.","[].stock":"number. Sum of all movement quantities (IN positive, OUT negative) of the material at the branch, in the storage unit. May differ from get_material.stock (materials_locations) — e.g. 9689 vs 9700 for material 120802 at branch 907 on dev1."},"example_request":{"material_id":119197,"location":907},"example":[{"stock":67.1985}],"master_rules":["Master inventory keeps two stock figures per branch: materials_locations.stock (what the dashboard, sales deduction and list_materials/get_material show) and the movement ledger summed here. Quote get_material.stock to users; use this only to detect drift, and report a mismatch rather than \"fixing\" it.","Always send location — the dashboard material pages never call this route."],"caveats":["Only numeric ids: the handler builds raw SQL without error handling."]},"parameters":{"type":"object","properties":{"material_id":{"type":"number","description":"Material id (list_materials)."},"location":{"type":"number","description":"ONE branch id (list_locations)."}},"required":["material_id","location"]},"module":"materials","safety":"read"},{"name":"list_inventory","description":"GET /api/inventory. Why: Legacy stock board of one branch: active materials linked to the branch with ledger stock, tenant average cost, unit, low-stock level and stock value. CURRENTLY RETURNS 500 FOR MASTER TENANTS (server count query bug) — for branch stock use list_inventory_movements (current_stock, avg_cost, total_value per material × branch), get_material_stock (one material) or list_materials. No page of the dashboard menu uses it any more. Master tenants: always HTTP 500 \"Unknown column 'm.is_active'\" (backend bug) — use list_inventory_movements or get_material_stock instead. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_inventory\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/inventory","why":"Legacy stock board of one branch: active materials linked to the branch with ledger stock, tenant average cost, unit, low-stock level and stock value. CURRENTLY RETURNS 500 FOR MASTER TENANTS (server count query bug) — for branch stock use list_inventory_movements (current_stock, avg_cost, total_value per material × branch), get_material_stock (one material) or list_materials. No page of the dashboard menu uses it any more.","response":{"description":"Intended 200 → { data, total }, rows ordered by stock DESC. Master tenants: the count query selects only id/customer_id/name in its derived table but filters on m.is_active, so every call fails with 500 { message: \"Internal server error\", error: \"Unknown column 'm.is_active' in 'where clause'\" } (verified live on dev1 with and without loc/_limit). Fields below are from the handler code (not observable live); the example is illustrative.","fields":{"data":"array. One row per active material of the branch (page of _limit when sent).","data[].id":"int. Material id (list_materials / get_material / get_material_stock / get_material_movements).","data[].name":"string. Material name.","data[].description":"string | null. Material description.","data[].stock":"number. SUM of every ledger qty (inventory table) for this material across ALL branches of the tenant — the join ignores loc, so it is NOT the branch stock. Branch stock: list_inventory_movements current_stock or get_material_stock with location.","data[].physical_stock":"number. materials.physical_stock (legacy last physically counted quantity, tenant-level), 0 when null.","data[].price":"number | null. materials.avg_price — tenant-level weighted average COST per stock unit (not a selling price). Branch cost is avg_cost in list_inventory_movements.","data[].unit":"string | null. Stock unit name (e.g. \"Kilogram\"); stock/physical_stock are in this unit.","data[].low_stock":"number | null. Low-stock alert threshold in the stock unit.","data[].diff":"number. stock − physical_stock (computed in Node).","data[].total":"number. Stock value = stock × price rounded to 2 decimals (0 when price is null), in the tenant currency.","total":"int. Number of matching materials (ignores paging)."},"master_rules":["Do not rely on this tool for master tenants: it returns 500 on every call until the backend count query is fixed. Answer stock questions with list_inventory_movements (per material × branch: current_stock, avg_cost, total_value) or get_material_stock (one material, one branch).","Even when it works, `stock` is the tenant-wide ledger sum (all branches) and `price` is the tenant average cost — never present them as one branch's stock or value.","Dashboard: no page in the current dashboard menu calls it. Three unlinked legacy pages still do — /dashboard/inventory/inventory (sends loc from a single branch dropdown, k, cat = material category name, _page, _limit 10), /dashboard/inventory/inventory-overview and /dashboard/reporting/inventory (loc = user's branch by default, _page 1, _limit 30; shows Name, Price, Stock, Unit Type, Total). The live Inventory Overview page uses list_materials instead.","Send exactly one `loc`; for several branches call once per branch.","Stock only changes through the dashboard/POS flows that write these rows: POS sales (sync_invoice → out/sold, refunds → refund), purchase orders received/approved in the dashboard (in; MCP can only create drafts with create_draft_purchase_order and read list_supplier_invoices — it cannot approve), stock transfers (list_stock_transfers → transferred_out/in), wastage batches (list_wastage → wastage), approved inventory counts (list_inventory_counts → adjustment) and work orders (list_work_orders → consumed/produced). This tool only reads.","Material ids come from list_materials / get_material; branch ids from list_locations. For one material's on-hand number use get_material_stock (same SUM of ledger qty)."],"example":{"data":[{"id":120802,"name":"Gotcha Recipe","description":null,"stock":9689,"physical_stock":0,"price":2.4616,"unit":"Kilogram","low_stock":0,"diff":9689,"total":23850.44}],"total":1}},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"ONE branch id (list_locations)."},"k":{"type":"string","description":"Case-insensitive substring match on the material name (LIKE %k%; `%` and `_` act as wildcards)."},"cat":{"type":"string","description":"PRODUCT category name (exact, trimmed): keeps materials used in the recipe/BOM (product_materials) of a non-archived product whose type equals it."},"brand_id":{"type":"string","description":"Brand id (list_brands): keeps materials used by non-archived products whose category is in a group linked to that brand."},"_page":{"type":"number","description":"1-based page, used only with _limit."},"_limit":{"type":"number","description":"Page size."}},"required":["loc"]},"module":"materials","safety":"read"},{"name":"get_materials_stats","description":"GET /api/materials/stats. Why: The four header numbers of the dashboard Materials page for a branch scope: total materials, active, recipe materials (rm) and low-stock count (branch stock below low_stock). Quick context before list_materials; for the items themselves use list_materials (and get_material for one). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_materials_stats\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/materials/stats","why":"The four header numbers of the dashboard Materials page for a branch scope: total materials, active, recipe materials (rm) and low-stock count (branch stock below low_stock). Quick context before list_materials; for the items themselves use list_materials (and get_material for one).","response":{"description":"200 → { total, active, recipes, low_stock } (all ints; all 0 when no allowed branch matches). 500 → { error: \"Failed to fetch materials stats\", detail }. On dev1 (Sep 2026) the route is missing → 400 { error: \"Invalid material ID\" } — fall back to list_materials counts (pagination.total with is_active 1 / 0, material_type rm).","fields":{"total":"int. Materials on the scoped branches, active AND inactive.","active":"int. Of those, is_active = 1. Dashboard \"Active\" card.","recipes":"int. Of those, material_type = rm (recipe outputs), active or not. Dashboard \"Recipes\" card.","low_stock":"int. Materials with low_stock > 0 whose summed stock over the scoped branches is below low_stock (active or not). Dashboard \"Low Stock\" card (red when > 0).","error":"string. Error only: \"Failed to fetch materials stats\" (500) or, on dev1, \"Invalid material ID\" (400).","detail":"string. Error only (500): database error text."},"example_request":{"loc":"907"},"example":{"total":2250,"active":2247,"recipes":902,"low_stock":0},"master_rules":["The dashboard Materials page shows Total Materials = total, Active = active, Recipes = recipes, Low Stock = low_stock, with the same loc as the list (omitted when \"All\"); the Type/Status/Category/search filters do NOT change these cards.","total and recipes include deactivated materials; the list defaults to active only, so pagination.total of list_materials is usually lower.","To act on low stock: list_materials {loc} and pick rows with low_stock > 0 and stock < low_stock, then their suppliers_prices → create_draft_purchase_order or create_stock_transfer."],"caveats":["Example is derived from live list_materials counts at loc 907 (2247 active + 3 inactive, 902 prepared) because the route is not live on dev1; low_stock illustrative.","With several branches, low_stock compares the SUM of branch stock with the per-unit threshold."]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id or comma-separated ids (list_locations)."}},"required":[]},"module":"materials","safety":"read"},{"name":"list_material_categories","description":"GET /api/inventory/categories. Why: Material category tree of the tenant (top-level categories and their subcategories, max 2 levels; separate from product categories). Use the ids as category_id on create_material / update_material and as `cat` to filter list_materials; pick parents for create_material_category. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_material_categories\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/inventory/categories","why":"Material category tree of the tenant (top-level categories and their subcategories, max 2 levels; separate from product categories). Use the ids as category_id on create_material / update_material and as `cat` to filter list_materials; pick parents for create_material_category.","response":{"description":"200 → { success: true, data }. flat: data = [category] ordered top-level first (parent_id null), then by parent, then name. tree: data = top-level categories each with children[] (subcategories with children []). No categories → data []. 500 → { success: false, error, code }.","fields":{"success":"boolean. true on success.","data":"array. Flat list (format=flat) or root nodes (tree).","data[].id":"int. Material category id — pass as category_id to create_material / update_material, as cat to list_materials, as id to get/update/delete_material_category, as parent_id for a subcategory.","data[].name":"string. Category name (unique among non-deleted siblings, case-insensitive).","data[].description":"string | null. Description (\"\" = none).","data[].parent_id":"int | null. Parent category id; null = top level. Max depth 2 (top level → subcategory).","data[].customer_id":"int. Tenant id. Not needed.","data[].color":"string. Colour string (\"\" = none). Not used by the dashboard.","data[].image":"string. Image path/URL as stored (\"\" = none); relative paths are served under <base>/api/uploads/.","data[].is_deleted":"0. Always 0 — deleted categories are filtered out.","data[].children":"array. Tree format only: subcategories of this node (same fields, each with children []).","data[].children[].id":"int. Material category id — pass as category_id to create_material / update_material, as cat to list_materials, as id to get/update/delete_material_category, as parent_id for a subcategory.","data[].children[].name":"string. Category name (unique among non-deleted siblings, case-insensitive).","data[].children[].description":"string | null. Description (\"\" = none).","data[].children[].parent_id":"int | null. Parent category id; null = top level. Max depth 2 (top level → subcategory).","data[].children[].customer_id":"int. Tenant id. Not needed.","data[].children[].color":"string. Colour string (\"\" = none). Not used by the dashboard.","data[].children[].image":"string. Image path/URL as stored (\"\" = none); relative paths are served under <base>/api/uploads/.","data[].children[].is_deleted":"0. Always 0 — deleted categories are filtered out.","data[].children[].children":"array. Tree format only: always [] (max depth 2).","error":"string. Error only (500): \"Failed to list material categories\" or DB error text.","code":"string | undefined. Error only: error code when set."},"example_request":{"format":"flat"},"example":{"success":true,"data":[{"id":2,"name":"Farm","description":"","parent_id":null,"customer_id":437,"color":"","image":"","is_deleted":0},{"id":4,"name":"Frozen","description":"","parent_id":null,"customer_id":437,"color":"","image":"","is_deleted":0},{"id":6,"name":"Meat","description":"","parent_id":null,"customer_id":437,"color":"","image":"","is_deleted":0},{"id":3,"name":"Dairy","description":"","parent_id":2,"customer_id":437,"color":"","image":"","is_deleted":0},{"id":1,"name":"Meat","description":"","parent_id":2,"customer_id":437,"color":"","image":"","is_deleted":0},{"id":5,"name":"Meat","description":"","parent_id":4,"customer_id":437,"color":"","image":"","is_deleted":0}]},"master_rules":["The Material Categories page (Inventory → Material Categories) calls this with format=tree (+ k from its search box), flattens it into Name / Parent / Description rows and pages it client-side; the materials list, material form and filters call it with format=flat and show \"Parent › Child\" labels.","A material has ONE category_id (a top-level or a subcategory). list_materials cat=<id> matches that exact id only."],"caveats":["Tree shape (format omitted), same data: [{\"id\": 2, \"name\": \"Farm\", \"description\": \"\", \"parent_id\": null, \"customer_id\": 437, \"color\": \"\", \"image\": \"\", \"is_deleted\": 0, \"children\": [{\"id\": 3, \"name\": \"Dairy\", \"description\": \"\", \"parent_id\": 2, \"customer_id\": 437, \"color\": \"\", \"image\": \"\", \"is_deleted\": 0, \"children\": []}, {\"id\": 1, \"name\": \"Meat\", \"description\": \"\", \"parent_id\": 2, \"customer_id\": 437, \"color\": \"\", \"image\": \"\", \"is_deleted\": 0, \"children\": []}]}]","Tree + k drops matching subcategories whose parent name does not match (e.g. k=\"da\" returns [] although \"Dairy\" exists under \"Farm\")."]},"parameters":{"type":"object","properties":{"format":{"type":"string","enum":["flat","tree"],"description":"\"flat\" → flat array (each row has parent_id)."},"k":{"type":"string","description":"Case-insensitive substring of the category name (LIKE %k%)."}},"required":[]},"module":"material_categories","safety":"read"},{"name":"get_material_used_in_recipes","description":"GET /api/materials/:id/used-in-recipes. Why: Where-used for one material: every recipe that has it as an ingredient, with the quantity and unit per recipe batch, and the recipe's own output material. Run it before changing units/conversion/yield/cost, deactivating (delete_material) or advising substitutions; then get_recipe for the full recipe and list_products / get_product to see which products and modifier items sell through those recipes (sync_invoice deducts along them). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_material_used_in_recipes\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/materials/:id/used-in-recipes","why":"Where-used for one material: every recipe that has it as an ingredient, with the quantity and unit per recipe batch, and the recipe's own output material. Run it before changing units/conversion/yield/cost, deactivating (delete_material) or advising substitutions; then get_recipe for the full recipe and list_products / get_product to see which products and modifier items sell through those recipes (sync_invoice deducts along them).","response":{"description":"200 → { message: \"Used-in recipes fetched successfully\", data }. data = one row per recipe line using the material ([] = unused). 400 { message: \"Valid material id is required\" }; 404 { message: \"Material not found\" } (unknown or another tenant's id); 500 { message: \"Failed to fetch recipes using this material\", detail }.","fields":{"message":"string. \"Used-in recipes fetched successfully\" or the error reason.","data":"array. Recipe lines that consume this material.","data[].recipe_id":"int. Recipe id (get_recipe / update_recipe).","data[].recipe_name":"string. Recipe name.","data[].yield_qty":"number. Output quantity one batch of the recipe produces (in recipe_unit).","data[].recipe_unit":"string. Unit of yield_qty.","data[].recipe_mode":"\"stockable\" | \"phantom\". stockable = the recipe's output is stocked (produced by work orders); phantom = ingredients are deducted at sale time.","data[].qty":"number. Quantity of THIS material per recipe batch, in qty_unit (the material's ingredient unit).","data[].qty_unit":"string | null. Unit of qty.","data[].yield_pct":"number | null. Line-level yield % override (newer build; null = use the material's). dev1 returns wastage_pct instead.","data[].material_yield_percentage":"number | null. The material's own yield_percentage (newer build).","data[].effective_yield":"number. Yield used for costing = yield_pct → material yield → 100 (newer build).","data[].wastage_pct":"number. dev1 build (Sep 2026) only: line wastage %, replaced by the yield fields above in the newer build.","data[].item_type":"\"mat\" | \"sr\". Line type: mat = material ingredient, sr = sub-recipe.","data[].material_id":"int | null. The recipe's OUTPUT material (rm, active) — open it with get_material. null when the recipe has no output material.","data[].material_name":"string. Output material name (else the recipe name).","data[].material_sku":"string | null. Output material SKU.","data[].material_mode":"string | null. Output material mode.","data[].material_type":"string | null. Output material type (rm).","detail":"string. Error only (500): database error text."},"example_request":{"id":119158},"example":{"message":"Used-in recipes fetched successfully","data":[{"recipe_id":17224,"recipe_name":"Gotcha Recipe","yield_qty":0.782,"recipe_unit":"Kilogram","recipe_mode":"stockable","qty":1000,"qty_unit":"Gram","wastage_pct":0,"item_type":"mat","material_id":120802,"material_name":"Gotcha Recipe","material_sku":"mat-437-120802","material_mode":"stockable","material_type":"rm"},{"recipe_id":16779,"recipe_name":"Test","yield_qty":0.8,"recipe_unit":"Kilogram","recipe_mode":"stockable","qty":500,"qty_unit":"Gram","wastage_pct":0,"item_type":"mat","material_id":119197,"material_name":"Test","material_sku":"mat-437-119197","material_mode":"stockable","material_type":"rm"}]},"master_rules":["The dashboard material detail page (BOM / where-used tab) calls it for raw materials and stockable recipe materials and lists output material name (else recipe name), recipe name, SKU, mode chip (recipe_mode), qty and qty_unit; clicking opens the output material, \"Edit recipe\" opens the recipe.","qty is per recipe batch in the ingredient unit; storage-unit usage = qty / conversionrate of this material. Cost impact per batch = qty × avg_price / conversionrate ÷ (effective_yield/100).","Products/modifier items are linked to recipes (get_product → recipes); a sale deducts the recipe's ingredients (phantom) or its stocked output (stockable). Deactivating a material used here breaks those deductions."],"caveats":["Tenant check is on the material only; rows come from the tenant's recipes."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Material id (list_materials)."}},"required":["id"]},"module":"materials","safety":"read"},{"name":"list_inventory_movements","description":"GET /api/inventory/movements-v3. Why: Stock movement summary (dashboard Inventory → Movement page): one row per material × branch that moved in the date range, with opening stock, totals per movement type (purchased in, transferred in/out, produced, sold, consumed, wasted, adjusted …), closing stock, live current stock, branch average cost and stock value, plus variance between ledger closing and live stock. Use it to answer \"how much X came in / went out / is on hand at branch Y\" and for stock valuation. Drill into single transactions with get_material_movements (one material) or get_inventory_movements_ledger (all materials). Read only — it never changes stock. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_inventory_movements\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/inventory/movements-v3","why":"Stock movement summary (dashboard Inventory → Movement page): one row per material × branch that moved in the date range, with opening stock, totals per movement type (purchased in, transferred in/out, produced, sold, consumed, wasted, adjusted …), closing stock, live current stock, branch average cost and stock value, plus variance between ledger closing and live stock. Use it to answer \"how much X came in / went out / is on hand at branch Y\" and for stock valuation. Drill into single transactions with get_material_movements (one material) or get_inventory_movements_ledger (all materials). Read only — it never changes stock.","response":{"description":"200 → { success: true, data, pagination }. Source: inventory_movement (daily aggregate per material, branch, movement type and day — not the transaction ledger), materials_locations (live branch stock and cost), materials (active only, tenant = session). Only material × branch pairs that have at least one movement inside the date range AND a materials_locations row are listed; materials that did not move are omitted. Ordered by material_name ASC. Quantities are in the material's stock unit (material_unit); outflow columns are shown as positive numbers. Errors → 500 { success: false, error }.","fields":{"success":"boolean. true on 200.","data":"array. One row per material × branch on this page.","data[].material_id":"int. Material id (get_material, get_material_movements, get_inventory_movements_ledger material_ids).","data[].material_name":"string. Material name.","data[].material_unit":"string | null. Stock unit name (e.g. \"Kilogram\"); every quantity in the row is in this unit.","data[].location_id":"int. Branch id.","data[].location_name":"string | null. Branch name.","data[].opening_stock":"number. Signed sum of all movements before start_date at this branch (stock at the start of the range). Equals closing_stock when start_date is omitted.","data[].stock_in":"number (≥ 0). mov_type in inside the range: purchase receipts (\"Supplier Order\"), manual additions, imports.","data[].stock_out":"number (≥ 0). mov_type out inside the range, shown positive: recipe ingredients deducted by POS sales (PHANTOM_SALE / STOCKABLE_SALE), older work orders, negative imports.","data[].transferred_in":"number (≥ 0). Received from other branches by stock transfers.","data[].transferred_out":"number (≥ 0). Sent to other branches by stock transfers (shown positive).","data[].transfer_return":"number (≥ 0). Transfer shortfall returned to this (source) branch.","data[].consumed":"number (≥ 0). Ingredients consumed by work orders (shown positive).","data[].produced":"number (≥ 0). Output produced by work orders.","data[].waste":"number (≥ 0). Wastage (mov_type wastage or legacy waste), shown positive.","data[].sold":"number (≥ 0). Material sold on POS invoices (mov_type sold), shown positive.","data[].purchase_return":"number (≥ 0). Returned to suppliers (shown positive). Not a dashboard column.","data[].adjustment_type_in":"number (≥ 0). Positive inventory-count adjustments.","data[].adjustment_type_out":"number (≥ 0). Negative inventory-count adjustments, shown positive.","data[].closing_stock":"number. Signed sum of all movements up to end_date (ledger stock at the end of the range). Includes movement types that have no column (e.g. refund).","data[].current_stock":"number. Live branch stock now (materials_locations.stock, the figure the POS uses), independent of the date range.","data[].avg_cost":"number. Branch weighted-average cost per stock unit (materials_locations.avg_cost), tenant currency; 0 when unknown.","data[].total_value":"number. current_stock × avg_cost — current stock value at this branch (not at end_date). Negative when stock is negative.","data[].expected_closing_stock":"string (3 decimals). opening_stock + stock_in + transferred_in + transfer_return + produced + adjustment_type_in − stock_out − transferred_out − consumed − sold − waste − purchase_return − adjustment_type_out. Differs from closing_stock when uncolumned types (refund, …) exist or start_date was omitted.","data[].variance":"string (3 decimals). closing_stock − current_stock: ledger stock at end_date vs live stock (non-zero when end_date is in the past or the live counter drifted from the ledger).","data[].variance_percentage":"number. variance / closing_stock × 100, 2 decimals; 0 when closing_stock is 0.","pagination":"object. Paging info.","pagination.page":"int. Page returned.","pagination.limit":"int. Page size used.","pagination.total":"int. Number of material × branch pairs matching (all pages).","pagination.totalPages":"int. ceil(total / limit); 0 when total is 0."},"master_rules":["Always send start_date and end_date (YYYY-MM-DD, both inclusive). Omitting start_date makes opening_stock the all-time total (verified live: opening_stock == closing_stock) and expected_closing_stock double-counts.","Read on-hand stock from current_stock and its value from total_value (current_stock × avg_cost) — both are live and branch-specific. closing_stock is the ledger total at end_date; use it for \"stock on date X\".","Quantities are in material_unit (the stock unit). Outflow columns (stock_out, sold, consumed, waste, transferred_out, purchase_return, adjustment_type_out) are returned positive — subtract them; the transaction tools return them negative.","A material that did not move in the range is not listed. To get stock for every material use get_material_stock per material or list_materials.","Dashboard Inventory → Movement page: sends start_date/end_date = the From/To pickers (both default to today), loc = the location filter (defaults to the user's branch; master tenants can pick several → csv), material_name = search box, _page, _limit 10 default. Columns: Location, Particulars (material_name), Unit, Avg Cost (formatted as money), Opening Stock, Stock In, Transferred In, Produced, Stock Out, Transferred Out, Transfer Return, Sold, Consumed, Waste, Adjustment In/Out, Closing Stock, Current Stock (3 decimals), Total Value (2 decimals), Expected Closing Stock, Variance, Variance % — purchase_return is not shown. Clicking a row opens a drawer with get_material_movements (id = material_id, location = location_id, same dates, _page, _limit 25).","The page's Excel download calls GET /api/inventory/movements-v3/export-excel with the same filters (no pagination); there is no MCP tool for it — page through this tool instead.","Stock only changes through the dashboard/POS flows that write these rows: POS sales (sync_invoice → out/sold, refunds → refund), purchase orders received/approved in the dashboard (in; MCP can only create drafts with create_draft_purchase_order and read list_supplier_invoices — it cannot approve), stock transfers (list_stock_transfers → transferred_out/in), wastage batches (list_wastage → wastage), approved inventory counts (list_inventory_counts → adjustment) and work orders (list_work_orders → consumed/produced). This tool only reads.","Material ids come from list_materials / get_material; branch ids from list_locations. For one material's on-hand number use get_material_stock (same SUM of ledger qty)."],"example":{"success":true,"data":[{"material_id":120802,"material_name":"Gotcha Recipe","material_unit":"Kilogram","location_id":907,"location_name":"Default location","opening_stock":-111,"stock_in":0,"stock_out":0,"transferred_in":0,"transferred_out":200,"transfer_return":0,"consumed":0,"produced":10000,"waste":0,"sold":0,"purchase_return":0,"adjustment_type_in":0,"adjustment_type_out":0,"closing_stock":9689,"current_stock":9700,"avg_cost":2.4616,"total_value":23877.52,"expected_closing_stock":"9689.000","variance":"-11.000","variance_percentage":-0.11}],"pagination":{"page":1,"limit":3,"total":3,"totalPages":1}}},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id or comma-separated ids (list_locations)."},"material_name":{"type":"string","description":"Case-insensitive substring match on the material name (LIKE %…%; `%`/`_` are wildcards)."},"start_date":{"type":"string","description":"From date YYYY-MM-DD, inclusive (movements are stored per calendar day)."},"end_date":{"type":"string","description":"To date YYYY-MM-DD, inclusive."},"_page":{"type":"number","description":"1-based page."},"_limit":{"type":"number","description":"Rows (material × branch pairs) per page."}},"required":[]},"module":"inventory_movements","safety":"read"},{"name":"get_inventory_movements_ledger","description":"GET /api/inventory/movements-ledger. Why: Transaction ledger across materials (dashboard Reports → Movement Report): every stock movement row — sale deductions, purchase receipts, transfers, wastage, count adjustments, work-order consumption/production, returns — with signed quantity, unit cost, value, reference document, user and a running balance per material × branch. Use it to audit what changed stock, by whom and why (filter by branch, materials, movement types, dates). For per-period totals per material use list_inventory_movements; for one material's history get_material_movements. Read only. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_inventory_movements_ledger\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/inventory/movements-ledger","why":"Transaction ledger across materials (dashboard Reports → Movement Report): every stock movement row — sale deductions, purchase receipts, transfers, wastage, count adjustments, work-order consumption/production, returns — with signed quantity, unit cost, value, reference document, user and a running balance per material × branch. Use it to audit what changed stock, by whom and why (filter by branch, materials, movement types, dates). For per-period totals per material use list_inventory_movements; for one material's history get_material_movements. Read only.","response":{"description":"200 → { success: true, data, pagination }. Source: inventory table (one row per stock transaction), joined with materials (same tenant; inactive materials included), locations and users. Default order newest first. Errors → 500 { error: \"Internal server error\" }.","fields":{"success":"boolean. true on 200.","data":"array. Ledger lines on this page.","data[].id":"int. Ledger row id (inventory.id).","data[].timestamp":"string (ISO 8601, UTC). When the movement was recorded. Convert to the branch timezone for display.","data[].material_id":"int. Material id.","data[].material_name":"string. Material name.","data[].material_unit":"string | null. Stock unit name; quantity_delta and running_balance are in this unit.","data[].location_id":"int. Branch where stock changed.","data[].location_name":"string | null. Branch name.","data[].movement_type":"string. Movement type (sign of the quantity in brackets; quantities are signed and their sum is the on-hand stock). in (+): stock received — reference \"Supplier Order <po uuid>\" (purchase order approved/received in the dashboard; get_purchase_order), \"Manual Stock Addition\", \"Import Inventory\" (Excel import, can also be negative), \"RETURN_PREPARED_<product_id>\" (prepared item put back on cancel). out (−): generic deduction — \"PHANTOM_SALE_<customer_invoice_id>\" (recipe ingredients deducted by a POS sale, sync_invoice), \"STOCKABLE_SALE_<customer_invoice_id>\" (stockable recipe sold), \"WORK_ORDER_<uuid>\" (older work orders), \"Import Inventory\". sold (−): \"INVOICE_<customer_invoice_id>\" material sold on a POS invoice. refund (+): \"INVOICE_…REFUND…\" stock restored by an invoice refund. wastage (−; legacy spelling waste): \"WASTAGE_BATCH_<batch_number>\" approved wastage batch (list_wastage) or \"WASTAGE_<order>\" item wasted on refund/cancel. transferred_out (−, source branch) / transferred_in (+, destination branch): \"Stock transfer <uuid> - Approved quantity\" / \"- Received quantity\" (list_stock_transfers). transfer_return (+, source): shortfall of a transfer returned to the source. consumed (−) / produced (+): \"WORK_ORDER_<UUID>\" ingredients used and output made by a work order (list_work_orders). purchase_return (−): \"Purchase Return <uuid>\" goods sent back to the supplier. adjustment (±): \"Inventory count <branch> - <date> - Approved (ID: <count id>)\" variance posted by an approved inventory count (get_inventory_count id).","data[].quantity_delta":"number. Signed quantity in the stock unit: positive = stock added, negative = stock removed.","data[].unit_cost":"string | null. Cost per stock unit at the time of the movement (VARCHAR, e.g. \"18.1956\"), tenant currency. null when not recorded (typical for PHANTOM_SALE out rows, wastage and purchase returns).","data[].total_value":"number. quantity_delta × unit_cost (0 when unit_cost is null); negative for outflows. Not rounded.","data[].reference_id":"string | null. Source document text, see movement_type for the formats (Supplier Order <uuid>, INVOICE_<id>, PHANTOM_SALE_<id>, Stock transfer <uuid> - …, WASTAGE_BATCH_<no>, WORK_ORDER_<uuid>, Purchase Return <uuid>, Inventory count … (ID: <id>)).","data[].expiry_date":"string | null. Batch expiry date entered on receipt (free text, usually YYYY-MM-DD).","data[].user_id":"int | null. User who recorded it.","data[].recorded_by":"string | null. That user's username.","data[].running_balance":"number. Cumulative SUM of quantity_delta for this material × branch in chronological order, computed ONLY over rows matching the filters: with no date/type filter it is the stock on hand right after this movement; with start_date it restarts from 0 at start_date (opening stock excluded) and with mov_types it sums only those types.","pagination":"object. Paging info.","pagination.page":"int. Page returned.","pagination.limit":"int. Page size used (after clamping to 1–1000).","pagination.total":"int. Matching ledger rows (all pages).","pagination.totalPages":"int. ceil(total / limit), at least 1."},"master_rules":["Signs: quantity_delta > 0 adds stock, < 0 removes it; the sum over all rows of a material × branch is its stock. running_balance is a true on-hand figure only when you send no start_date and no mov_types; otherwise it is a within-filter cumulative sum (verified live: 9800 with start_date vs real stock 9689). For opening/closing per period use list_inventory_movements.","Keep ranges short and pages ≤ 1000 rows: the window function scans every matching row (an unfiltered request at offset ≥ 2250 returned 502 on dev1).","Map reference_id to documents: \"Supplier Order <uuid>\" → get_purchase_order / list_supplier_invoices; \"Stock transfer <uuid> …\" → get_stock_transfer; \"WASTAGE_BATCH_<batch_number>\" → list_wastage; \"WORK_ORDER_<UUID>\" → list_work_orders / get_work_order; \"Inventory count … (ID: n)\" → get_inventory_count id n; INVOICE_/PHANTOM_SALE_/STOCKABLE_SALE_<customer_invoice_id> → the POS order (order tools).","Dashboard Reports → Movement Report: sends start_date/end_date (From/To, default today), location (branch multi-select csv, default the user's branch), material_ids (material multi-select csv), mov_types (type chips), group_by, _page, _limit 25 (1000 when grouped; groups and subtotals Qty/Value are built client-side). Columns: Date/Time (timestamp), Material (+ expiry dot), Location (auto when several branches), Movement Type, Qty (signed), Running Balance, Unit Cost (money), Total Value (2 decimals), Reference, Recorded By, Expiry.","The report's \"Export full data (.xlsx)\" asks for _limit 10000 with only the dates (server caps it at 1000 and it drops the branch/material/type filters); \"Export current view\" exports the loaded rows. There is no export tool — page through this tool.","Stock only changes through the dashboard/POS flows that write these rows: POS sales (sync_invoice → out/sold, refunds → refund), purchase orders received/approved in the dashboard (in; MCP can only create drafts with create_draft_purchase_order and read list_supplier_invoices — it cannot approve), stock transfers (list_stock_transfers → transferred_out/in), wastage batches (list_wastage → wastage), approved inventory counts (list_inventory_counts → adjustment) and work orders (list_work_orders → consumed/produced). This tool only reads.","Material ids come from list_materials / get_material; branch ids from list_locations. For one material's on-hand number use get_material_stock (same SUM of ledger qty)."],"example":{"success":true,"data":[{"id":12472803,"timestamp":"2026-08-12T10:48:20.000Z","material_id":120802,"material_name":"Gotcha Recipe","material_unit":"Kilogram","location_id":907,"location_name":"Default location","movement_type":"transferred_out","quantity_delta":-200,"unit_cost":"18.1956","total_value":-3639.12,"reference_id":"Stock transfer fe1ecd13-f615-4326-b6a1-482b5353a3c6 - Approved quantity","expiry_date":null,"user_id":4734,"recorded_by":"Nexus Cafe","running_balance":9689}],"pagination":{"page":1,"limit":20,"total":3290,"totalPages":165}}},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id or comma-separated ids (list_locations)."},"material_ids":{"type":"string","description":"Material id or comma-separated ids (list_materials)."},"mov_types":{"type":"string","description":"Comma-separated movement types (exact): in, out, sold, refund, wastage, waste, transferred_in, transferred_out, transfer_return, consumed, produced, purchase_r…"},"k":{"type":"string","description":"Case-insensitive substring match on the material name (LIKE %k%; `%`/`_` are wildcards)."},"start_date":{"type":"string","description":"From date YYYY-MM-DD, inclusive (created_at ≥ start_date 00:00)."},"end_date":{"type":"string","description":"To date YYYY-MM-DD, inclusive (created_at < end_date + 1 day)."},"group_by":{"type":"string","enum":["none","location","material","movementType","day","week"],"description":"Only changes the sort: location → by branch name, material → by material name, movementType → by type, then newest first."},"_page":{"type":"number","description":"1-based page."},"_limit":{"type":"number","description":"Rows per page, clamped to 1–1000."}},"required":[]},"module":"inventory_movements","safety":"read"},{"name":"get_material_movements","description":"GET /api/inventory/:id/movs. Why: Stock history of ONE material at a branch (dashboard material page History tab and the Movement page drawer): every ledger movement newest first with signed qty, unit cost, reference document, user and brand, plus the material's current stock at that branch on the first row. Use it to explain why a material's stock is what it is. Material id from list_materials; for all materials use get_inventory_movements_ledger, for period totals list_inventory_movements. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_material_movements\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/inventory/:id/movs","why":"Stock history of ONE material at a branch (dashboard material page History tab and the Movement page drawer): every ledger movement newest first with signed qty, unit cost, reference document, user and brand, plus the material's current stock at that branch on the first row. Use it to explain why a material's stock is what it is. Material id from list_materials; for all materials use get_inventory_movements_ledger, for period totals list_inventory_movements.","response":{"description":"200 → { data, total }. Rows from the inventory ledger for this material at the branch, newest first (created_at DESC, id DESC). No tenant/material existence check → unknown id gives { data: [], total: 0 }. Errors → 400 { error: \"location is required\" }, 500 { error: \"Internal server error\" }.","fields":{"data":"array. Movements on this page.","data[].id":"int. Ledger row id.","data[].mov_type":"string. Movement type (sign of the quantity in brackets; quantities are signed and their sum is the on-hand stock). in (+): stock received — reference \"Supplier Order <po uuid>\" (purchase order approved/received in the dashboard; get_purchase_order), \"Manual Stock Addition\", \"Import Inventory\" (Excel import, can also be negative), \"RETURN_PREPARED_<product_id>\" (prepared item put back on cancel). out (−): generic deduction — \"PHANTOM_SALE_<customer_invoice_id>\" (recipe ingredients deducted by a POS sale, sync_invoice), \"STOCKABLE_SALE_<customer_invoice_id>\" (stockable recipe sold), \"WORK_ORDER_<uuid>\" (older work orders), \"Import Inventory\". sold (−): \"INVOICE_<customer_invoice_id>\" material sold on a POS invoice. refund (+): \"INVOICE_…REFUND…\" stock restored by an invoice refund. wastage (−; legacy spelling waste): \"WASTAGE_BATCH_<batch_number>\" approved wastage batch (list_wastage) or \"WASTAGE_<order>\" item wasted on refund/cancel. transferred_out (−, source branch) / transferred_in (+, destination branch): \"Stock transfer <uuid> - Approved quantity\" / \"- Received quantity\" (list_stock_transfers). transfer_return (+, source): shortfall of a transfer returned to the source. consumed (−) / produced (+): \"WORK_ORDER_<UUID>\" ingredients used and output made by a work order (list_work_orders). purchase_return (−): \"Purchase Return <uuid>\" goods sent back to the supplier. adjustment (±): \"Inventory count <branch> - <date> - Approved (ID: <count id>)\" variance posted by an approved inventory count (get_inventory_count id).","data[].material_id":"int. The material (= path id).","data[].qty":"number. Signed quantity in the material's stock unit (get_material unit): + added, − removed.","data[].price":"string | null. Cost per stock unit at the time of the movement (VARCHAR, e.g. \"11.0136\"), tenant currency; null when not recorded.","data[].reference":"string | null. Source document text (formats under mov_type).","data[].created_at":"string \"YYYY-MM-DD HH:mm:ss\" in UTC (no zone suffix). Convert to the branch timezone for display, as the dashboard does.","data[].expiry_date":"string \"YYYY-MM-DD\" | null. Batch expiry entered on receipt.","data[].user_id":"int | null. User who recorded the movement.","data[].brand_id":"int | null. Brand of the sale line that caused the movement (list_brands), usually null.","data[].location_id":"int. Branch.","data[].location_name":"string | null. Branch name.","data[].brand_name":"string | null. Brand name for brand_id.","data[].username":"string | null. Username of user_id.","data[].stock":"number. ONLY on the first row of each page (absent on the others and when data is empty): current on-hand stock = SUM of every ledger qty of this material at the branch, all dates — not affected by start_date/end_date or paging.","total":"int. Number of movements matching location + dates (all pages)."},"master_rules":["Always send location (one branch) and, when paging, both _page and _limit (e.g. _page 1, _limit 25). Without _limit every movement of the material is returned.","Read current stock from data[0].stock (branch, all-time, in the stock unit); qty signs: + in, − out. The sum of qty over all pages without date filters equals data[0].stock.","Dashboard: (1) material edit page History tab (/dashboard/inventory/materials/edit-detail?id=…&loc=…) sends location = selected branch(es) csv, _page, _limit 10, skipped for phantom recipes; shows type chip (mov_type), qty with +/− colour, date (created_at converted to branch time), expiry, reference (inventory-count references link to the count, invoice references to the order), user (username), branch column when several branches. (2) Inventory → Movement page row drawer sends location = row location_id, start_date/end_date = page dates, _page, _limit 25; shows Date, Type label (in→Stock In, out→Stock Out, sold→Sale, consumed→Recipe Consumption, produced→Production, wastage→Waste, adjustment→Adjustment, purchase_return→Return, transfers), signed qty (3 decimals), unit cost (price), reference, user.","Stock only changes through the dashboard/POS flows that write these rows: POS sales (sync_invoice → out/sold, refunds → refund), purchase orders received/approved in the dashboard (in; MCP can only create drafts with create_draft_purchase_order and read list_supplier_invoices — it cannot approve), stock transfers (list_stock_transfers → transferred_out/in), wastage batches (list_wastage → wastage), approved inventory counts (list_inventory_counts → adjustment) and work orders (list_work_orders → consumed/produced). This tool only reads.","Material ids come from list_materials / get_material; branch ids from list_locations. For one material's on-hand number use get_material_stock (same SUM of ledger qty)."],"example":{"data":[{"id":12472802,"mov_type":"transferred_out","material_id":119196,"qty":-200,"price":"11.0136","reference":"Stock transfer fe1ecd13-f615-4326-b6a1-482b5353a3c6 - Approved quantity","created_at":"2026-08-12 10:48:20","expiry_date":null,"user_id":4734,"brand_id":null,"location_id":907,"location_name":"Default location","brand_name":null,"username":"Nexus Cafe","stock":-21387897.14439},{"id":12472793,"mov_type":"in","material_id":119196,"qty":1000.921387,"price":"0.1256","reference":"Supplier Order d39ca7ba-1468-4f0b-855e-94d042731c3f","created_at":"2026-08-04 16:49:29","expiry_date":null,"user_id":4734,"brand_id":null,"location_id":907,"location_name":"Default location","brand_name":null,"username":"Nexus Cafe"}],"total":41}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Material id (list_materials / get_material)."},"location":{"type":"string","description":"Branch id (list_locations)."},"start_date":{"type":"string","description":"From date YYYY-MM-DD, inclusive."},"end_date":{"type":"string","description":"To date YYYY-MM-DD, inclusive (created_at < end_date + 1 day)."},"_page":{"type":"number","description":"1-based page."},"_limit":{"type":"number","description":"Rows per page."}},"required":["id","location"]},"module":"materials","safety":"read"},{"name":"list_suppliers","description":"GET /api/suppliers. Why: Supplier (vendor) directory of the tenant with per-supplier counters: how many active materials are linked to it (items_in_catalog), how many purchase orders it has (total_orders) and how many are still pending delivery. Use it to find a supplier id before get_supplier / update_supplier / delete_supplier, before reading its price list (list_supplier_materials sup=<id>) or its purchase orders (list_supplier_invoices sid=<id>). Supplier ↔ material links and purchase prices live in list_supplier_materials; material master data in list_materials / get_material. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_suppliers\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/suppliers","why":"Supplier (vendor) directory of the tenant with per-supplier counters: how many active materials are linked to it (items_in_catalog), how many purchase orders it has (total_orders) and how many are still pending delivery. Use it to find a supplier id before get_supplier / update_supplier / delete_supplier, before reading its price list (list_supplier_materials sup=<id>) or its purchase orders (list_supplier_invoices sid=<id>). Supplier ↔ material links and purchase prices live in list_supplier_materials; material master data in list_materials / get_material.","response":{"description":"200 → bare array of suppliers (every suppliers column + three counters), unordered (roughly by id), max 10000 rows. 500 → { error: \"Failed to fetch suppliers\" }.","fields":{"[]":"array. One object per supplier ([] = none / no match).","[].id":"int. Supplier id. Pass as `id` to get_supplier / update_supplier / delete_supplier, as `sup` to list_supplier_materials, as `sid` to list_supplier_invoices; purchases store it as table_id.","[].name":"string | null. Supplier display name (not unique — two suppliers may share a name).","[].description":"string | null. Free-text description.","[].customer_id":"int. Tenant id. Always your own tenant; not needed.","[].location_id":"int | null. Branch of the user who last created/saved the supplier (the API overwrites it with that user's home branch on every create/update). Suppliers are tenant-wide — this does not restrict where the supplier can be used.","[].address":"string | null. Postal address (max 200 chars). Copied into a PO as info_address.","[].logo":"string | null. Relative upload path of the logo image (\"\" = none).","[].tax_pct":"number | null. Supplier tax % as typed in the dashboard (2 decimals). Informational only — PO VAT is NOT computed from it. Tax is out of scope.","[].payments":"string | null. JSON string of accepted payment methods: \"{\\\"is_cash\\\":bool,\\\"is_credit\\\":bool,\\\"is_cheque\\\":bool}\". Parse it before reading.","[].trade_license":"string | null. Relative upload path of the trade-licence document (\"\" = none), not a licence number.","[].contact_name":"string | null. Contact person.","[].contact_email":"string | null. Contact email. PO emails go here when can_recieve_email = 1. Copied into a PO as info_email.","[].contact_phone":"string | null. Contact phone (max 45 chars). Copied into a PO as info_phone.","[].trn_certificate":"string | null. Relative upload path of the TRN certificate document (\"\" = none; max 45 chars).","[].can_recieve_email":"0 | 1. 1 = the supplier is emailed when a PO is created/confirmed (dashboard checkbox \"Receive purchase order emails\", default on).","[].items_in_catalog":"int. Distinct ACTIVE materials linked to this supplier (suppliers_of_material) at the `loc` branches. Dashboard column \"Material Catalog\". Details: list_supplier_materials sup=<id>.","[].total_orders":"int. Number of purchase rows (all statuses, all branches, drafts and archived included) with table_id = this supplier. Dashboard column \"Total Orders\".","[].pending_deliveries":"int. Purchase rows of this supplier with status \"pending\" (confirmed, not yet received). Dashboard column \"Pending Deliveries\"."},"example":[{"id":825,"name":"Grocery Supplier","description":"Grocery items","customer_id":437,"location_id":907,"address":"Warehouse 4, Al Quoz, Dubai","logo":"","tax_pct":5,"payments":"{\"is_cash\":true,\"is_credit\":false,\"is_cheque\":false}","trade_license":"","contact_name":"Sample Contact","contact_email":"orders@supplier.example","contact_phone":"+971500000001","trn_certificate":"","can_recieve_email":1,"items_in_catalog":5,"total_orders":7,"pending_deliveries":1}],"master_rules":["Suppliers are tenant-wide: `loc` never hides a supplier, it only changes items_in_catalog. To see which materials a supplier sells at a branch use list_supplier_materials sup=<id>&loc=<branch>.","The dashboard Suppliers table shows name, Material Catalog (items_in_catalog), Pending Deliveries (pending_deliveries) and Total Orders (total_orders), with Edit / Link materials / Delete actions. Show the same columns.","Names are not unique (the same name can appear twice): identify suppliers by id, and when the user names one that matches several rows, ask which (show contact_name / contact_email).","total_orders / pending_deliveries count purchase orders; list them with list_supplier_invoices sid=<id> (st=pending for the pending ones)."]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id or comma-separated ids (list_locations)."},"k":{"type":"string","description":"Case-insensitive substring match on supplier name (LIKE %k%; `%` and `_` act as wildcards)."}},"required":[]},"module":"suppliers","safety":"read"},{"name":"list_supplier_materials","description":"GET /api/supplier-materials. Why: A supplier's price list: the materials linked to a supplier at a branch with the supplier's last purchase price (purchase_price) and the material's average cost. Use it to pick material ids and unit prices for draft PO lines (create_draft_purchase_order / update_draft_purchase_order_lines) — the dashboard PO pickers show exactly this list. Supplier ids from list_suppliers, branch ids from list_locations; material details and units via get_material, stock via list_inventory. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_supplier_materials\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/supplier-materials","why":"A supplier's price list: the materials linked to a supplier at a branch with the supplier's last purchase price (purchase_price) and the material's average cost. Use it to pick material ids and unit prices for draft PO lines (create_draft_purchase_order / update_draft_purchase_order_lines) — the dashboard PO pickers show exactly this list. Supplier ids from list_suppliers, branch ids from list_locations; material details and units via get_material, stock via list_inventory.","response":{"description":"200 → bare array of materials (default), or { data, pagination } when filter, _page or _limit is sent. Server filters: tenant, materials.is_active = 1, name LIKE k, mode, prepared exclusion, branch availability, link filter; ORDER BY name. 400/500 → { error }.","fields":{"[]":"array. Materials — the bare-array shape (none of filter/_page/_limit sent).","[].id":"int. Material id. Use as material_id on PO/draft lines; details via get_material.","[].name":"string. Material name (may contain leading spaces/tabs as imported). Snapshot it as the line `name`.","[].unit":"string | null. Material purchase/storage unit (e.g. \"Pieces\", \"Gram\", \"Kg\"). PO quantities and prices are per this unit — no unit conversion happens on POs.","[].type":"string | null. Material category name.","[].material_type":"string | null. Material kind code (e.g. \"mat\"). Informational.","[].mode":"string. \"stockable\" | \"phantom\". POs use stockable materials.","[].avg_price":"number | null. Material's current average cost per unit (from stock receipts). Fallback unit price when purchase_price is null.","[].sku":"string. Material SKU (\"\" when none).","[].supplier_id":"int | null. = sup when the material is linked to this supplier at the branch(es); null when not linked.","[].material_id":"int | null. = id when linked; null when not linked. Use `id`, not this.","[].purchase_price":"number | null. This supplier's stored purchase price per unit at the branch(es) (MAX over branches when several). null = none recorded. The dashboard PO pickers pre-fill the line unit price with it (else avg_price).","[].brand_ids":"int[]. Brand ids attached to the material ([] = none). A PO line may carry one of them as brand_id.","data":"array. Materials in the { data, pagination } shape (filter/_page/_limit sent).","data[].id":"Same as [].id.","data[].name":"Same as [].name.","data[].unit":"Same as [].unit.","data[].type":"Same as [].type.","data[].material_type":"Same as [].material_type.","data[].mode":"Same as [].mode.","data[].avg_price":"Same as [].avg_price.","data[].sku":"Same as [].sku.","data[].supplier_id":"Same as [].supplier_id.","data[].material_id":"Same as [].material_id.","data[].purchase_price":"Same as [].purchase_price.","data[].brand_ids":"Same as [].brand_ids.","pagination":"object. Only in the { data, pagination } shape.","pagination.total":"int. Materials matching all filters (all pages).","pagination.page":"int. Page returned.","pagination.limit":"int. Page size used.","pagination.totalPages":"int. ceil(total / limit).","error":"string. 400 \"Supplier ID is required\" (sup missing) or 500 \"Failed to fetch supplier materials\"."},"example_request":{"sup":825,"loc":907},"example":[{"id":112157,"name":"\t SRC-BT-86CBPR-SG3 BB86-BB92 INTERLOCK SHIMANO CERAMIC COATED BLACK","unit":"Pieces","type":"CEMA BEARINGS","material_type":"mat","mode":"stockable","avg_price":1.2343,"sku":"mat-437-112157","supplier_id":825,"material_id":112157,"purchase_price":2,"brand_ids":[]},{"id":112295,"name":" Atx 27.5 In Vibrant Blue","unit":"Pieces","type":"SUN AND SAND SPORTS LLC","material_type":"mat","mode":"stockable","avg_price":3,"sku":"mat-437-112295","supplier_id":825,"material_id":112295,"purchase_price":3,"brand_ids":[]}],"master_rules":["Always send sup (the supplier id) — the old supplier_id param is ignored and the call fails with 400.","Pass loc = the PO's branch: links and purchase prices are per branch, so a supplier linked at 907 shows nothing with loc=908.","For a draft PO line take material_id = id, name = name, price = purchase_price ?? avg_price (what the dashboard picker pre-fills), and quantities in `unit` (no unit conversion on POs).","Unlinked materials can still be ordered: the dashboard adds them to a PO and afterwards offers to link them to the supplier (Link materials). There is no MCP tool to create links or change purchase prices.","Expect the bare-array shape unless you send filter/_page/_limit; with them read data[] and pagination.total.","Stock on hand is not here — use list_inventory / get_material_stock."]},"parameters":{"type":"object","properties":{"sup":{"type":"number","description":"Supplier id (list_suppliers)."},"loc":{"type":"string","description":"Branch id or csv (list_locations)."},"k":{"type":"string","description":"Case-insensitive substring match on material name (LIKE %k%; `%`/`_` are wildcards)."},"filter":{"type":"string","enum":["linked","unlinked","all"],"description":"\"linked\" → only materials linked to the supplier; \"unlinked\" → only materials NOT linked; \"all\" (or any other value) → both."},"mode":{"type":"string","enum":["stockable","phantom","all"],"description":"Material mode filter: \"stockable\" (default — what POs use), \"phantom\", or \"all\" for no filter."},"exclude_prepared":{"type":"string","enum":["0","false","1","true"],"description":"Prepared / stockable-recipe materials are excluded by default (they are made in-house, not purchased)."},"include_recipes":{"type":"string","enum":["0","1","true","false"],"description":"\"1\"/\"true\" → include prepared / stockable-recipe materials (same as exclude_prepared=0)."},"_page":{"type":"number","description":"1-based page."},"_limit":{"type":"number","description":"Page size."}},"required":["sup"]},"module":"suppliers","safety":"read"},{"name":"list_supplier_invoices","description":"GET /api/supplier-invoices. Why: Purchase-order board of the tenant (the dashboard \"Purchase Orders\" list): confirmed POs, drafts, partially received and received ones, with supplier snapshot, totals, PO status and supplier response. Use it to find a PO/draft uuid for get_purchase_order and the draft tools, to see a supplier's orders (sid from list_suppliers) or what is still pending delivery. Read-only: approving, sending, receiving and cancelling POs happen only in the dashboard. Stock arrives only when the dashboard receives a PO (then visible in list_inventory / list_inventory_movements). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_supplier_invoices\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/supplier-invoices","why":"Purchase-order board of the tenant (the dashboard \"Purchase Orders\" list): confirmed POs, drafts, partially received and received ones, with supplier snapshot, totals, PO status and supplier response. Use it to find a PO/draft uuid for get_purchase_order and the draft tools, to see a supplier's orders (sid from list_suppliers) or what is still pending delivery. Read-only: approving, sending, receiving and cancelling POs happen only in the dashboard. Stock arrives only when the dashboard receives a PO (then visible in list_inventory / list_inventory_movements).","response":{"description":"200 → bare array of purchase headers (no lines — use get_purchase_order). Server filters: tenant, loc (single), st, sid, archive (default active only), and either k OR from+to. ORDER BY oid DESC, max 10000. 500 → { error: \"Database Error\" }.","fields":{"[]":"array. One object per purchase (PO or draft), ORDER BY oid DESC.","[].id":"string. LAST 12 CHARACTERS of uuid (display key) — NOT a numeric id. Use uuid for get_purchase_order.","[].oid":"int. PO number shown as \"PO-<oid>\" (invoice_id, else the row id). Search with k.","[].items_number":"int. Number of lines.","[].subtotal":"number. Sum of line totals (qty × unit price) before VAT, as stored by the dashboard.","[].vat":"number. VAT AMOUNT for the whole PO (2 decimals), not a percent. VAT % = vat / subtotal × 100.","[].payable":"number. Grand total = subtotal + vat (the dashboard does not subtract discountamount).","[].discountamount":"number | null. Header discount amount (normally 0). Not applied to payable by the dashboard.","[].discountpct":"number | null. Legacy discount % (normally 0/null). Not needed.","[].paid":"number | null. Legacy paid amount (0/null). Not needed.","[].change":"int. SUPPLIER RESPONSE status (not money): 0 = Processing (not answered), 1 = Sent, 2 = Accepted, 3 = Declined — the dashboard \"Supplier Status\" pill.","[].note":"string | null. Header note (orders from stock order requests say \"From order request N\").","[].published_date":"string (ISO) | null. Purchase date the user entered (date only; null on drafts).","[].uuid":"string. PO uuid — the key for get_purchase_order and every PO/draft write tool.","[].created_at":"string (ISO datetime, tenant time zone rendered as UTC ISO). Creation time; the from/to filter uses its date.","[].customer_id":"int. Tenant id. Not needed.","[].location_id":"int. Branch the PO delivers to (list_locations).","[].info_name":"string | null. Supplier name snapshot at creation (dashboard \"Supplier\" column).","[].info_address":"string | null. Supplier address snapshot.","[].info_phone":"string | null. Supplier phone snapshot.","[].info_email":"string | null. Supplier email snapshot.","[].status":"string. PO status: draft | awaiting (draft marked Receive Later) | pending_approval (legacy) | pending (confirmed, sent to supplier, receivable) | partially_received (some drafts open after a short receipt) | delivered (received — stock posted; dashboard label \"Completed\") | canceled / cancelled | new / completed (legacy).","[].user":"string | null. Username of the creator.","[].table_id":"int | null. Supplier id (list_suppliers / get_supplier).","[].payment_method":"string | null. Legacy payment method, normally null. Filter `type` matches it.","[].your_order_ref":"string | null. Your reference (e.g. \"REQ-12\" for POs from order request 12). Search with k.","[].supplier_order_ref":"string | null. Supplier's reference.","[].is_draft":"0 | 1. 1 = draft (editable/deletable by MCP; not sent to the supplier; no stock).","[].parent_purchase_uuid":"string | null. For receive-shortfall drafts: uuid of the PO it was split from. null for normal POs and for drafts generated from stock order requests.","[].is_archive":"0 | 1. 1 = archived (read-only in the dashboard; hidden unless is_archive=1/all).","error":"string. 500 → { error: \"Database Error\" }."},"example_request":{"loc":907,"st":"all","from":"2026-05-01","to":"2026-05-31"},"example":[{"id":"aa9412cc3eb9","oid":6187,"items_number":1,"subtotal":12,"vat":0.6,"payable":12.6,"discountamount":0,"discountpct":0,"paid":0,"change":0,"note":"","published_date":null,"uuid":"52e14591-f11f-430c-8672-aa9412cc3eb9","created_at":"2026-05-19T16:16:14.000Z","customer_id":437,"location_id":907,"info_name":"Faisal Suppliers","info_address":"Industrial Area 2, Sharjah","info_phone":"+971500000003","info_email":"sales@faisal.example","status":"draft","user":"Nexus Cafe","table_id":881,"payment_method":null,"your_order_ref":"","supplier_order_ref":"","is_draft":1,"parent_purchase_uuid":"6f6db90f-2a7d-4910-82e9-3a0dae7e6e70","is_archive":0},{"id":"3a0dae7e6e70","oid":6180,"items_number":2,"subtotal":10,"vat":0.5,"payable":10.5,"discountamount":0,"discountpct":null,"paid":null,"change":2,"note":"","published_date":"2026-05-19T00:00:00.000Z","uuid":"6f6db90f-2a7d-4910-82e9-3a0dae7e6e70","created_at":"2026-05-19T14:41:09.000Z","customer_id":437,"location_id":907,"info_name":"Grocery Supplier","info_address":"Warehouse 4, Al Quoz, Dubai","info_phone":"+971500000001","info_email":"orders@supplier.example","status":"partially_received","user":"Nexus Cafe","table_id":825,"payment_method":null,"your_order_ref":"","supplier_order_ref":null,"is_draft":0,"parent_purchase_uuid":null,"is_archive":0}],"master_rules":["Send `st` for status (not `status`), `sid` for supplier, one branch in `loc`, and from+to together (YYYY-MM-DD). With no dates you get every PO ever (up to 10000) — pass a date range for normal questions, like the dashboard (default today–today).","Show rows like the dashboard table: \"PO-<oid>\", status label (pending → Pending, partially_received → Partially Received, delivered → Completed, canceled → Canceled, draft → Draft, awaiting → Awaiting), supplier status from `change` (0 Processing, 1 Sent, 2 Accepted, 3 Declined), supplier info_name, created_at date.","Lifecycle (dashboard only unless noted): draft (MCP can create/edit/delete) → Confirm (dashboard) turns the draft into a pending PO and emails the supplier (if can_recieve_email) → supplier Accepts/Declines (change 2/3) → Receive (dashboard, needs the supplier invoice number) posts stock-in and sets delivered; receiving less than ordered can split the shortfall into new drafts (parent becomes partially_received) → returns (purchase returns) only on delivered POs. Cancel and archive are also dashboard-only.","is_draft = 1 rows are the only ones MCP may change (update_draft_purchase_order_lines, delete_draft_purchase_order). Drafts with parent_purchase_uuid are receive shortfalls; drafts with no parent and your_order_ref \"REQ-<n>\" were generated from a stock order request (list_stock_order_requests / get_stock_order_request) — order requests become POs only through the dashboard.","Stock is posted only when the dashboard receives a PO (status → delivered); check list_inventory / list_inventory_movements for the resulting stock-in, never infer stock from pending POs.","`id` here is the last 12 chars of uuid, while get_purchase_order's `id` is the PO number — always key on uuid."]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"ONE branch id (list_locations): exact match on location_id."},"st":{"type":"string","enum":["all","draft","awaiting","pending_approval","pending","partially_received","delivered","canceled","cancelled","returned"],"description":"Status filter: all | draft | awaiting | pending_approval | pending | partially_received | delivered | canceled | cancelled | returned (POs with at least one su…"},"sid":{"type":"number","description":"Supplier id (list_suppliers): only POs of that supplier (table_id)."},"from":{"type":"string","description":"Start date YYYY-MM-DD on created_at in the tenant time zone (inclusive)."},"to":{"type":"string","description":"End date YYYY-MM-DD (inclusive)."},"k":{"type":"string","description":"Partial match on PO number (oid), supplier invoice number, system supplier invoice id or your_order_ref."},"is_archive":{"type":"string","enum":["0","1","all"],"description":"\"0\" (default) → active POs only; \"1\" → archived only; \"all\" → both."},"type":{"type":"string","description":"Exact payment_method filter; \"All\" → none."}},"required":[]},"module":"purchase_orders","safety":"read"},{"name":"list_stock_transfers","description":"GET /api/stock-transfer-invoices. Why: Inter-branch stock transfer board (Inventory → Transfer Request): every transfer between the tenant's branches with status, source/destination branch, transfer_no, value and who requested/approved/dispatched/received it. Use it to find a transfer id for get_stock_transfer (lines, on-hand, history) → update_stock_transfer / update_stock_transfer_items (pending only), to find the transfer you just created with create_stock_transfer (its response has no id), and to answer 'what is in transit / waiting for approval'. Branch ids come from list_locations. Server-side scope: only transfers whose source or destination is in the calling user's allowed branches. Read only — approve/dispatch/receive/complete/reject are dashboard actions. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_stock_transfers\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/stock-transfer-invoices","why":"Inter-branch stock transfer board (Inventory → Transfer Request): every transfer between the tenant's branches with status, source/destination branch, transfer_no, value and who requested/approved/dispatched/received it. Use it to find a transfer id for get_stock_transfer (lines, on-hand, history) → update_stock_transfer / update_stock_transfer_items (pending only), to find the transfer you just created with create_stock_transfer (its response has no id), and to answer 'what is in transit / waiting for approval'. Branch ids come from list_locations. Server-side scope: only transfers whose source or destination is in the calling user's allowed branches. Read only — approve/dispatch/receive/complete/reject are dashboard actions.","response":{"description":"200 → { success: true, message, data: { data: transfer[], total, page, limit } }. Rows are raw stock_transfer columns plus from_location / to_location names and *_by_username. Tenant = session; only transfers where from_location_id OR to_location_id is in the user's allow_locations (when the user has a list). ORDER BY created_at DESC. Lines are NOT included — call get_stock_transfer. Errors → 500 { success: false, message: \"Failed to get all stock transfers\", error }.","fields":{"success":"true. false only on error (500 {success:false, message:\"Failed to get all stock transfers\", error}).","message":"string. \"Stock transfers fetched successfully\".","data":"object. { data, total, page, limit }.","data.data":"array. Transfers on this page, newest created_at first.","data.data[].id":"int. Transfer id (numeric). Pass as `id` to get_stock_transfer / update_stock_transfer / update_stock_transfer_items. The dashboard shows invoice_id (fallback id) as \"Order ID\".","data.data[].items_number":"int | null. Number of lines at the last save.","data.data[].subtotal":"number | null. Sum of line subtotals (qty × price) — cost value of the transfer, not a sale.","data.data[].vat":"number | null. VAT AMOUNT (not a percent): subtotal × branch VAT % when the creator ticked \"Include VAT\", else 0. update_stock_transfer_items recomputes it as subtotal × 5% whenever it was > 0.","data.data[].payable":"number | null. subtotal + vat (transfer value incl. VAT).","data.data[].discountamount":"number | null. Legacy header column, always 0/null for transfers. Not needed.","data.data[].discountpct":"number | null. Legacy, 0/null. Not needed.","data.data[].paid":"number | null. Legacy, 0/null. Not needed.","data.data[].change":"number | null. Legacy, 0/1/null. Not needed.","data.data[].note":"string | null. Free-text header note (varchar 300); the dashboard create form sends \"\".","data.data[].uuid":"string | null. Client-generated transfer uuid; links lines (items[].stock_transfer_uuid) and history. Not accepted by get_stock_transfer (numeric id only).","data.data[].created_at":"string (ISO, UTC). Creation time; the list is sorted by it (newest first) and from/to filter on its UTC date.","data.data[].customer_id":"int. Tenant id. Not needed.","data.data[].from_location_id":"int. SOURCE branch (list_locations) — stock leaves here on dispatch.","data.data[].info_name":"string | null. Username of the creator (dashboard fills it from the logged-in user); the list shows it as the \"User\" column with an avatar.","data.data[].info_address":"string | null. Legacy, empty. Not needed.","data.data[].info_email":"string | null. Creator email.","data.data[].info_phone":"string | null. Legacy, empty. Not needed.","data.data[].status":"string. draft | pending | approved | dispatched | received | completed | rejected (legacy rows may say delivered / cancelled). See master_rules for the lifecycle.","data.data[].user":"string | null. Creator username.","data.data[].table_id":"int | null. Legacy (order-request accept flow wrote a user id here). Not needed.","data.data[].payment_method":"string | null. Legacy. Not needed.","data.data[].order_id":"int | null. Legacy sequence from the old order-request accept flow. Not needed.","data.data[].invoice_id":"int | null. Per-branch running number (per creator branch). Dashboard \"Order ID\" column = invoice_id || id.","data.data[].to_location_id":"int. DESTINATION branch (list_locations) — stock arrives here on receive.","data.data[].customer_location_id":"int | null. Branch of the user/API key that created the transfer (not necessarily the source).","data.data[].email":"string | null. Legacy email column. Not needed.","data.data[].delivery_date":"string (ISO) | null. Delivery date set at creation (dashboard date picker, optional).","data.data[].user_id":"int | null. Creator user id.","data.data[].is_edit":"string | null. Legacy. Not needed.","data.data[].username":"string | null. Creator username.","data.data[].transfer_no":"string | null. Human transfer number \"STR-<year>-<5 digits>\" (global sequence). Quote it to the user.","data.data[].requested_by":"int. User id that created the request.","data.data[].approved_by":"int | null. User id that approved (dashboard).","data.data[].dispatched_by":"int | null. User id that dispatched (dashboard).","data.data[].received_by":"int | null. User id that received (dashboard).","data.data[].requested_at":"string (ISO, UTC). Request time.","data.data[].approved_at":"string (ISO) | null. Approval time.","data.data[].dispatched_at":"string (ISO) | null. Dispatch time (source stock deducted).","data.data[].received_at":"string (ISO) | null. Receive time (destination stock added).","data.data[].notes":"string | null. INTERNAL JSON metadata (auto-created / variance-draft links), not a user note — parsed into transfer_meta by get_stock_transfer. Never write it.","data.data[].priority":"string | null. Optional label (low | normal | high | urgent); the dashboard never sets it.","data.data[].expected_delivery_date":"string (ISO date) | null. Optional; the dashboard never sets it.","data.data[].updated_at":"string (ISO). Last update time.","data.data[].rejected_by":"int | null. User id that rejected (dashboard).","data.data[].rejected_at":"string (ISO) | null. Rejection time.","data.data[].parent_transfer_id":"int | null. For a variance re-supply draft: the transfer whose shortfall it re-sends.","data.data[].from_location":"string | null. Source branch name.","data.data[].to_location":"string | null. Destination branch name.","data.data[].requested_by_username":"string | null. Username of requested_by.","data.data[].approved_by_username":"string | null. Username of approved_by.","data.data[].dispatched_by_username":"string | null. Username of dispatched_by.","data.data[].received_by_username":"string | null. Username of received_by.","data.total":"int. Number of transfers matching the filters (all pages).","data.page":"int | string. Page served: 1 when _page omitted, else _page echoed as sent (string).","data.limit":"int | string. Page size: 10 when _limit omitted, else _limit echoed as sent (string)."},"example_request":{"status":"pending","from_location_id":907,"_limit":5},"example":{"success":true,"message":"Stock transfers fetched successfully","data":{"data":[{"id":7777,"items_number":2,"subtotal":5841.84,"vat":292.092,"payable":6133.932,"discountamount":0,"discountpct":0,"paid":0,"change":0,"note":"","uuid":"fe1ecd13-f615-4326-b6a1-482b5353a3c6","created_at":"2026-08-12T10:48:07.000Z","customer_id":437,"from_location_id":907,"info_name":"Nexus Cafe","info_address":null,"info_email":"admin@devnexus.co","info_phone":null,"status":"dispatched","user":"Nexus Cafe","table_id":null,"payment_method":null,"order_id":null,"invoice_id":44,"to_location_id":909,"customer_location_id":907,"email":null,"delivery_date":null,"user_id":4734,"is_edit":null,"username":"Nexus Cafe","transfer_no":"STR-2026-01628","requested_by":4734,"approved_by":4734,"dispatched_by":4734,"received_by":null,"requested_at":"2026-08-12T10:48:07.000Z","approved_at":"2026-08-12T10:48:16.000Z","dispatched_at":"2026-08-12T10:48:21.000Z","received_at":null,"notes":null,"priority":null,"expected_delivery_date":null,"updated_at":"2026-08-12T10:48:20.000Z","rejected_by":null,"rejected_at":null,"parent_transfer_id":null,"from_location":"Default location","to_location":"Iqbal Town","requested_by_username":"Nexus Cafe","approved_by_username":"Nexus Cafe","dispatched_by_username":"Nexus Cafe","received_by_username":null}],"total":47,"page":1,"limit":"5"}},"master_rules":["The dashboard Transfer Request list calls this with from and to (both default to today), _page (1-based) and _limit (10; 5/10/25 selectable), plus status, from_location_id, to_location_id when chosen. It also sends k from its search box, but the server ignores k — there is no text search; filter by status/branch/date and match transfer_no or info_name yourself.","Each row the dashboard shows: User = info_name (avatar initials), Order ID = invoice_id || id, Status pill = status, From = from_location (fallback \"Location <from_location_id>\"), To = to_location, Date = created_at; the eye icon opens the transfer page by id (get_stock_transfer).","Lifecycle as the dashboard shows it (status pill): draft (only variance re-supply drafts) → pending (request created) → approved (source manager sets approved_qty per line and clicks Approve) → dispatched (Move to Dispatch: approved_qty copied to dispatched_qty and stock is DEDUCTED at from_location_id, TRANSFERRED_OUT movement) → received (destination enters received_qty per line + variance_reason when it differs; stock is ADDED at to_location_id, TRANSFERRED_IN movement) → completed (Complete button). pending can be rejected; approved can be moved back to pending. The MCP can only create and edit pending/draft transfers — approve, dispatch, receive, complete, reject and variance resolution happen in the dashboard (Inventory → Transfer Request → transfer page / Variances page).","What to tell the user per status: pending = waiting for source approval; approved = approved, not yet sent (no stock moved); dispatched = in transit (source already deducted, destination not yet added); received = counted in at destination, may have variances; completed = closed; rejected = cancelled before approval.","To find a transfer you just created: call with status=pending (and from_location_id/to_location_id) — newest first — and match uuid; create_stock_transfer returns no id.","Stock does not move when a transfer is created or edited: list_inventory / get_material_stock / list_inventory_movements only change after the dashboard dispatches (source −) and receives (destination +) it.","Branch names: from_location / to_location are already in the row; use list_locations for the branch list (the MCP key's allow_locations limits which branches you may act on)."]},"parameters":{"type":"object","properties":{"status":{"type":"string","enum":["draft","pending","approved","dispatched","received","completed","rejected","cancelled","delivered"],"description":"Exact status filter."},"from_location_id":{"type":"number","description":"Source branch id (list_locations): only transfers leaving this branch."},"to_location_id":{"type":"number","description":"Destination branch id (list_locations): only transfers arriving at this branch."},"from":{"type":"string","description":"Start date YYYY-MM-DD, compared to DATE(created_at) in UTC (inclusive)."},"to":{"type":"string","description":"End date YYYY-MM-DD, compared to DATE(created_at) in UTC (inclusive)."},"_page":{"type":"number","description":"1-based page."},"_limit":{"type":"number","description":"Rows per page."}},"required":[]},"module":"stock_transfers","safety":"read"},{"name":"list_stock_order_requests","description":"GET /api/stock-order-request. Why: Branch material order requests (Inventory → Order Requests): what a branch asked to buy, per request number REQ-<oid>, with status (Pending / Draft PO Created / Declined), total, item count and the draft PO it became (po_number). Use it to find a request uuid for get_stock_order_request → update_stock_order_request (Pending only), to check a request you created, or to see which requests already have POs (then get_purchase_order). Branch ids from list_locations. Read only — decline / create draft PO are dashboard actions. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_stock_order_requests\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/stock-order-request","why":"Branch material order requests (Inventory → Order Requests): what a branch asked to buy, per request number REQ-<oid>, with status (Pending / Draft PO Created / Declined), total, item count and the draft PO it became (po_number). Use it to find a request uuid for get_stock_order_request → update_stock_order_request (Pending only), to check a request you created, or to see which requests already have POs (then get_purchase_order). Branch ids from list_locations. Read only — decline / create draft PO are dashboard actions.","response":{"description":"200 → bare array of request rows (not wrapped, no total). Scope: tenant = session AND from_location_id IN the user's allow_locations (or the user's branch; [] when neither), + loc/st/k/from–to filters, ORDER BY oid DESC LIMIT 10000. Lines are not included (items_count only) — call get_stock_order_request. Errors → 500 { error: \"Failed to fetch order requests\" }.","fields":{"[]":"array. One row per order request.","[].id":"string. LAST 12 characters of the uuid (not a number!). Not needed — use uuid / oid.","[].oid":"int. Request number (invoice_id, fallback row id). The dashboard shows \"REQ-<oid>\".","[].subtotal":"number | null. Σ line qty × price.","[].vat":"number | null. VAT AMOUNT (subtotal × branch VAT % when Include VAT is ticked, else 0).","[].payable":"number | null. subtotal + vat — dashboard \"Total\" column.","[].change":"number | null. Legacy supplier-status flag (0). Not needed.","[].note":"string | null. Header note.","[].uuid":"string. Request uuid — pass to get_stock_order_request / update_stock_order_request.","[].delivery_date":"string (ISO) | null. Requested delivery date/time.","[].created_at":"string. Creation time converted to the tenant timezone (the trailing Z is not UTC).","[].customer_id":"int. Tenant id. Not needed.","[].to_location_id":"int | null. Destination branch; the dashboard sets it = from_location_id (the requesting branch) — and to the PO destination on the Draft PO page.","[].to_location":"string | null. Destination branch name.","[].customer_location_id":"int | null. Branch the invoice number sequence belongs to (= from_location_id).","[].from_location_id":"int | null. Requesting branch (list_locations); the list is scoped to the user's allowed branches by this column.","[].from_location":"string | null. Requesting branch name.","[].location_name":"string | null. Same as from_location — dashboard \"Location\" column.","[].info_name":"string | null. Requester name (dashboard fallback for \"Requested by\").","[].info_email":"string | null. Requester email.","[].status":"string. Pending | Draft PO Created | Declined (dashboard statuses); legacy rows: Requested, Approved, In Processing, Shipped, Delivered, Completed, Cancelled, …","[].user":"string | null. Creator username — dashboard \"Requested by\".","[].username":"string | null. Creator username.","[].table_id":"int | null. Legacy (0). Not needed.","[].payment_method":"string | null. Garbage legacy value (the insert writes a row count here). Not needed.","[].items_count":"int. Number of current lines — dashboard \"Items\" column.","[].purchase_uuid":"string | null. Draft PO created from this request (get_purchase_order).","[].po_number":"string | null. That PO's number, e.g. \"PO-55\"."},"example_request":{"st":"Pending","from":"2026-08-01","to":"2026-08-31"},"example":[{"id":"4338bd97b70a","oid":13,"subtotal":6000,"vat":300,"payable":6300,"change":0,"note":"","uuid":"59c4fb6e-fb85-4ab1-ac0b-4338bd97b70a","delivery_date":"2026-08-06T12:52:39.000Z","created_at":"2026-08-06T12:52:49.000Z","customer_id":437,"to_location_id":907,"to_location":"Default location","customer_location_id":907,"from_location_id":907,"from_location":"Default location","location_name":"Default location","info_name":"Nexus Cafe","info_email":"admin@devnexus.co","status":"Pending","user":"Nexus Cafe","username":"Nexus Cafe","table_id":0,"payment_method":"1","items_count":1,"purchase_uuid":null,"po_number":null},{"id":"8cbb626a9d44","oid":12,"subtotal":125.72,"vat":6.29,"payable":132,"change":0,"note":"","uuid":"d5bc4cdc-f6b6-4d04-bdcf-8cbb626a9d44","delivery_date":"2026-08-04T21:48:06.000Z","created_at":"2026-08-04T21:48:44.000Z","customer_id":437,"to_location_id":907,"to_location":"Default location","customer_location_id":907,"from_location_id":907,"from_location":"Default location","location_name":"Default location","info_name":"Nexus Cafe","info_email":"admin@devnexus.co","status":"Draft PO Created","user":"Nexus Cafe","username":"Nexus Cafe","table_id":0,"payment_method":"1","items_count":1,"purchase_uuid":"d39ca7ba-1468-4f0b-855e-94d042731c3f","po_number":"PO-55"}],"master_rules":["The dashboard Order Requests list always sends flow=order_request plus from and to (both today on first open; date pickers), and st, loc, k when set (\"All\" sends nothing). It shows: Request # = \"REQ-<oid>\", Location = location_name, Requested by = user (fallback username / info_name), Date = created_at (YYYY-MM-DD), Items = items_count, Total = payable in the tenant currency, Status pill (Pending orange, Draft PO Created green, Declined red); View opens /order-request/<uuid> (get_stock_order_request).","Order request lifecycle (dashboard Inventory → Order Requests): a branch creates a request (status \"Pending\") listing materials with qty (storage unit), cost and a SUPPLIER per line. While Pending it can be edited, Declined (status → \"Declined\") or converted with \"Create Draft PO\" (one draft purchase order per supplier; status → \"Draft PO Created\", purchase_uuid / po_number filled). Old rows may show legacy statuses (Requested, Approved, In Processing, Delivered, Completed, Cancelled…). The MCP can create and edit Pending requests only; declining and Create Draft PO are dashboard actions.","Order requests are NOT transfers and NOT purchase orders: they are a branch's shopping list. When a request shows Draft PO Created, the PO is at purchase_uuid / po_number (get_purchase_order); to buy directly with the MCP use create_draft_purchase_order.","Stock never moves from an order request; it moves when the resulting PO is received in the dashboard (list_inventory_movements)."]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"ONE requesting-branch id (list_locations); only requests whose from_location_id equals it."},"st":{"type":"string","description":"Exact status filter (case-insensitive): Pending | Draft PO Created | Declined (dashboard dropdown), or a legacy status."},"k":{"type":"string","description":"Substring search on request number (invoice_id/id), user, username or info_name."},"from":{"type":"string","description":"Start business day YYYY-MM-DD (from the branch opening hour, tenant timezone)."},"to":{"type":"string","description":"End business day YYYY-MM-DD (inclusive, up to the next day's opening hour)."}},"required":[]},"module":"stock_order_requests","safety":"read"},{"name":"list_wastage","description":"GET /api/wastage. Why: Wastage batches (spoiled / expired / damaged stock write-offs) of the tenant with status, reason, cost and creator — the dashboard Wastage page. Use it to find batch ids for get_wastage / update_wastage_draft / delete_wastage_draft and to report waste cost. Read only; the stock-out happens when a batch is approved in the dashboard. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_wastage\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/wastage","why":"Wastage batches (spoiled / expired / damaged stock write-offs) of the tenant with status, reason, cost and creator — the dashboard Wastage page. Use it to find batch ids for get_wastage / update_wastage_draft / delete_wastage_draft and to report waste cost. Read only; the stock-out happens when a batch is approved in the dashboard.","response":{"description":"200 → { success: true, message, data: { data: batch[], pagination: { total, page?, limit? } } }. Each batch = every wastage_batches column + location_name, created_by_name, approved_by_name, item_brand_names_csv. Lines are not included (get_wastage). Errors → 500 { success: false, message: \"Failed to get all wastage batches\", error }.","fields":{"success":"boolean. true on success.","message":"string. \"Wastage batches fetched successfully\".","data":"object. { data, pagination }.","data.data":"array. Batches of this page, newest first (ORDER BY id DESC).","data.data[].id":"int. Batch id → get_wastage / update_wastage_draft / delete_wastage_draft.","data.data[].batch_number":"string. \"WB-<year>-<5-digit seq>\", shown as the batch reference.","data.data[].wastage_reason":"string | null. Batch reason (applies to all lines). Dashboard reasons (value → label): expired → Expired, damaged → Damaged, spoiled → Spoiled, quality_issue → Quality Issue, contaminated → Contaminated, other → Other (with \"other\" the typed free text is sent instead). System batches also use e.g. invoice_cancellation, \"Customer complaint\".","data.data[].items_count":"int. Number of lines (distinct materials on create).","data.data[].total_cost":"number. Σ line total_cost as sent by the creator (not recomputed from materials).","data.data[].approval_status":"string. draft | pending | approved | rejected. approved = stock already deducted.","data.data[].requires_approval":"0 | 1. 1 = submit goes to pending (all manual batches); 0 only for batches created already approved.","data.data[].created_by":"int. Creator user id (list_users).","data.data[].approved_by":"int | null. Approver user id; null until approved.","data.data[].batch_notes":"string | null. Free-text note (system batches store e.g. the invoice reference).","data.data[].approval_notes":"string | null. Approver/rejecter note (reject prompt).","data.data[].location_id":"int. Branch the waste happened at (list_locations); stock-out happens here.","data.data[].customer_id":"int. Tenant id. Not needed.","data.data[].submitted_at":"string (ISO) | null. When moved to pending; null for drafts.","data.data[].approved_at":"string (ISO) | null. When approved.","data.data[].created_at":"string (ISO). Creation time (the list's date filter uses it).","data.data[].updated_at":"string (ISO). Last header update.","data.data[].reference_type":"string | null. Origin: manual (dashboard/MCP), invoice_cancellation, invoice_refund, order_cancellation, recipe_inherent, stock_transfer_variance.","data.data[].reference_id":"string | null. Id of the originating document (e.g. invoice uuid); null for manual.","data.data[].brand_id":"int | null. Batch brand (list_brands) — set when every line has the same brand.","data.data[].location_name":"string | null. Branch name (joined).","data.data[].created_by_name":"string | null. Creator username (joined).","data.data[].approved_by_name":"string | null. Approver username (joined).","data.data[].item_brand_names_csv":"string | null. Distinct brand names of the lines joined with \"|||\" (dashboard BRANDS column shows the first + \"+N more\").","data.pagination":"object. Paging info.","data.pagination.total":"int. Total matching batches (all pages).","data.pagination.page":"string. page as sent; ABSENT when page was not sent.","data.pagination.limit":"string. limit as sent; absent when not sent (then 10 rows)."},"master_rules":["The dashboard Wastage list sends { from, to (both default TODAY), loc_id (selected branches csv, \"\" = all), user (0 = all), page, limit (10/25/50), brand_filter (only when a brand is picked) } — so by default it shows only today's batches. Send from/to to cover the period the user means; omit them for all time.","Columns shown: batch_number, date (created_at), location_name, brands (item_brand_names_csv), reason (label; unknown values show as \"Others\"), items_count, total_cost (currency), status pill (draft info, pending warning, approved success, rejected error), created_by_name; action View Details → get_wastage. The search box does nothing server-side (no search param).","Lifecycle (dashboard): draft → \"Move to Approval\" (submit) → pending → Approve (needs inventory.wastage.approve) / Reject (optional notes) → approved | rejected. APPROVE is what moves stock: for every line it posts a WASTAGE stock-out at the batch's branch of qty / conversionrate storage units, priced at the material's avg_price. The agent cannot submit, approve or reject (those routes are not exposed; MCP always creates drafts). After preparing a draft tell the user to open Inventory → Wastage → the batch → Move to Approval, then an approver approves. Resulting movements: list_inventory_movements (wastage, reference \"WASTAGE_BATCH_<batch_number>\"). The dashboard's own create screen skips draft and creates pending batches directly.","Only approval_status draft batches may be changed by the agent (update_wastage_draft, delete_wastage_draft)."],"caveats":["k / status / _page / _limit from the old catalog are not read by the API (removed); the status param is approval_status."],"example_request":{"loc":"907","limit":5},"example":{"success":true,"message":"Wastage batches fetched successfully","data":{"data":[{"id":29,"batch_number":"WB-2026-00029","wastage_reason":"invoice_cancellation","items_count":1,"total_cost":1.1624,"approval_status":"pending","requires_approval":1,"created_by":4734,"approved_by":null,"batch_notes":"Auto-created from invoice cancellation — 6CAC39F5-C142-4FF2-8970-DF090FEA68EE","approval_notes":null,"location_id":907,"customer_id":437,"submitted_at":"2026-07-01T12:12:18.000Z","approved_at":null,"created_at":"2026-07-01T12:12:18.000Z","updated_at":"2026-07-01T12:12:18.000Z","reference_type":"invoice_cancellation","reference_id":"6CAC39F5-C142-4FF2-8970-DF090FEA68EE","brand_id":null,"location_name":"Default location","created_by_name":"Nexus Cafe","approved_by_name":null,"item_brand_names_csv":null},{"id":14,"batch_number":"WB-2026-00014","wastage_reason":"expired","items_count":1,"total_cost":5.4144,"approval_status":"approved","requires_approval":1,"created_by":4734,"approved_by":4734,"batch_notes":null,"approval_notes":null,"location_id":907,"customer_id":437,"submitted_at":"2026-04-30T09:16:16.000Z","approved_at":"2026-04-30T14:16:46.000Z","created_at":"2026-04-30T09:16:15.000Z","updated_at":"2026-04-30T09:16:45.000Z","reference_type":"manual","reference_id":null,"brand_id":473,"location_name":"Default location","created_by_name":"Nexus Cafe","approved_by_name":"Nexus Cafe","item_brand_names_csv":"Test "}],"pagination":{"total":4,"limit":"5"}}}},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id or csv of branch ids (list_locations)."},"from":{"type":"string","description":"Start date YYYY-MM-DD (created_at ≥ that day 00:00 in server time)."},"to":{"type":"string","description":"End date YYYY-MM-DD inclusive (created_at ≤ 23:59:59)."},"approval_status":{"type":"string","enum":["draft","pending","approved","rejected"],"description":"Exact status."},"user":{"type":"number","description":"Creator user id (list_users)."},"brand_filter":{"type":"number","description":"Brand id (list_brands): batches with at least one line of that brand."},"page":{"type":"number","description":"1-based page."},"limit":{"type":"number","description":"Rows per page."}},"required":[]},"module":"wastage","safety":"read"},{"name":"list_inventory_counts","description":"GET /api/inventory-counts/list. Why: Physical inventory counts (stock-take sessions) of a branch with every line (expected vs counted). Use it to find a count id and its status before get_inventory_count / update_inventory_count_materials / add_inventory_count_item, or to report variances. Read only — approval (which adjusts stock) happens in the dashboard. Count-sheet templates are list_inventory_count_sheets. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_inventory_counts\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/inventory-counts/list","why":"Physical inventory counts (stock-take sessions) of a branch with every line (expected vs counted). Use it to find a count id and its status before get_inventory_count / update_inventory_count_materials / add_inventory_count_item, or to report variances. Read only — approval (which adjusts stock) happens in the dashboard. Count-sheet templates are list_inventory_count_sheets.","response":{"description":"Always HTTP 200. { status: true, message, data: count[] } (data = [] when none). Each count = header + materials[] (5 fields per line). No customer filter in SQL — rows are selected by branch only, so always pass your own branch ids. Errors → { status: false, message: \"Error fetching inventory counts\", data: [] }.","fields":{"status":"boolean. true, also when there are no counts (then data = []); false only on an exception.","message":"string. \"Inventory count list found\" | \"You have not created any inventory count yet\" | \"Error fetching inventory counts\".","data":"array. Every matching count, newest first (ORDER BY id DESC). No pagination.","data[].id":"int. Count id → get_inventory_count / update_inventory_count_materials / add_inventory_count_item (inventory_count_id).","data[].start_date":"string. Count date exactly as the creator sent it — the dashboard sends \"Mon DD, YYYY\" (e.g. \"Apr 25, 2026\"); free text (VARCHAR).","data[].start_time":"string. Count time as sent — dashboard format \"hh:mm AM\" (e.g. \"03:41 PM\"); free text.","data[].location_id":"int. Branch the count belongs to (list_locations). Approval adjusts stock at this branch.","data[].name":"string. Count name (dashboard default \"<Branch> - <date> <time>\").","data[].type":"int. 0 = partial (chosen materials), 1 = full (every stockable material of the branch), 2 = from a count sheet.","data[].is_partial":"0 | 1. 1 for type 0, else 0 (the dashboard's type tabs use both).","data[].status":"string. draft | submitted | pending | approved | rejected (see lifecycle in master_rules). approved = stock already adjusted, final.","data[].created_at":"number. Unix timestamp in SECONDS (converted by the API).","data[].updated_at":"number. Unix timestamp in seconds (equals created_at on most rows; not bumped by line edits).","data[].created_by_user_id":"int. User id of the creator. The dashboard allows bulk edit / add item only to this user while draft.","data[].materials":"array. All lines of the count (no line id, no units — use get_inventory_count for those).","data[].materials[].material_id":"int. Material id (get_material / list_materials).","data[].materials[].counted_quantity":"number. Physically counted quantity, storage unit (up to 4 decimals).","data[].materials[].expected_quantity":"number. System stock snapshot stored when the line was created (storage unit). Approval adjusts stock by counted − expected using THIS value.","data[].materials[].price":"number. Unit cost per storage unit (the dashboard stores the material's avg_price). Variance cost = (counted − expected) × price.","data[].materials[].name":"string. Material name snapshot taken when the line was added."},"master_rules":["The dashboard Inventory Count list sends only loc (csv of selected branches) plus _page/_limit that the API ignores; search by name and the Partial / Full / Count Sheet tabs are client-side. Send status / type here to filter server-side instead.","Row columns in the dashboard: name, branch, type chip, items = materials.length, VARIANCE COST = Σ (counted − expected) × price, TOTAL COST = Σ counted × price, status chip (approved green, pending orange, rejected red, draft grey), action Review (pending) / View. Compute the same from materials[].","Summary cards: total counts (+ this week by created_at), Pending Review (status pending), branches, Discrepancies (any line where counted ≠ expected).","Lifecycle (dashboard): draft → \"Submit for Approval\" → pending → Approve / Reject / Send Back (→ draft); rejected → draft. APPROVE is what changes stock: for every line it posts counted_quantity − expected_quantity as an inventory ADJUSTMENT movement at the count's branch (price = line price; lines with 0 difference skipped), using the expected_quantity stored in the count, not live stock. The agent cannot submit, approve, reject or send back (change-status is not exposed) — after preparing a draft, tell the user to open Inventory → Inventory Count → the count and use Submit for Approval / Approve. The resulting adjustments are visible in list_inventory_movements (mov_type adjustment, reference \"Inventory count <name> - Approved (ID: <id>)\").","Only status draft may be edited by the agent (update_inventory_count_materials / update_inventory_count_item / add_inventory_count_item); pending counts are under review and approved ones already moved stock."],"caveats":["_page/_limit/k are not read by the API (removed).","start_date/start_time are display strings, not ISO."],"example_request":{"loc":"907"},"example":{"status":true,"message":"Inventory count list found","data":[{"id":477,"start_date":"Apr 25, 2026","start_time":"03:41 PM","location_id":907,"name":"Default location - Apr 25, 2026 03:41 PM","type":0,"is_partial":1,"status":"pending","created_at":1777113069,"updated_at":1777113069,"created_by_user_id":4734,"materials":[{"material_id":119197,"counted_quantity":10.45,"expected_quantity":0,"price":0.2873,"name":"Test"}]},{"id":476,"start_date":"Apr 25, 2026","start_time":"03:57 PM","location_id":907,"name":"Default location - Apr 25, 2026 03:57 PM","type":0,"is_partial":1,"status":"approved","created_at":1777112893,"updated_at":1777112893,"created_by_user_id":4734,"materials":[{"material_id":119197,"counted_quantity":10.9985,"expected_quantity":0,"price":0.2873,"name":"Test"}]}]}},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id or csv of branch ids (list_locations); counts whose location_id is any of them."},"status":{"type":"string","enum":["draft","submitted","pending","approved","rejected"],"description":"Exact status filter."},"type":{"type":"number","description":"Exact type filter: 0 partial, 1 full, 2 count sheet."}},"required":[]},"module":"inventory_counts","safety":"read"},{"name":"get_inventory_count","description":"GET /api/inventory-counts/by-id. Why: One inventory count with every line: line id, material, expected vs counted quantity, price and units. Needed before update_inventory_count_item (line ids), update_inventory_count_materials (full line list) or add_inventory_count_item (check the material is not already on the count), and to report variance. Read only. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_inventory_count\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/inventory-counts/by-id","why":"One inventory count with every line: line id, material, expected vs counted quantity, price and units. Needed before update_inventory_count_item (line ids), update_inventory_count_materials (full line list) or add_inventory_count_item (check the material is not already on the count), and to report variance. Read only.","response":{"description":"Always HTTP 200. { status: true, message: \"Inventory count found\", data: [count] }; not found → { status: true, message: \"You have not started this count yet\", data: [] }; exception → { status: false, message: \"Error fetching inventory count\", data: [] }. The id is not checked against your tenant — only use ids from list_inventory_counts.","fields":{"status":"boolean. true on success AND when the id does not exist (then data = []); false only on an exception.","message":"string. \"Inventory count found\" | \"You have not started this count yet\" (not found) | \"Error fetching inventory count\".","data":"array. [count] — one element when found, [] when not found. Read data[0].","data[].id":"int. Count id → get_inventory_count / update_inventory_count_materials / add_inventory_count_item (inventory_count_id).","data[].start_date":"string. Count date exactly as the creator sent it — the dashboard sends \"Mon DD, YYYY\" (e.g. \"Apr 25, 2026\"); free text (VARCHAR).","data[].start_time":"string. Count time as sent — dashboard format \"hh:mm AM\" (e.g. \"03:41 PM\"); free text.","data[].location_id":"int. Branch the count belongs to (list_locations). Approval adjusts stock at this branch.","data[].name":"string. Count name (dashboard default \"<Branch> - <date> <time>\").","data[].type":"int. 0 = partial (chosen materials), 1 = full (every stockable material of the branch), 2 = from a count sheet.","data[].is_partial":"0 | 1. 1 for type 0, else 0 (the dashboard's type tabs use both).","data[].status":"string. draft | submitted | pending | approved | rejected (see lifecycle in master_rules). approved = stock already adjusted, final.","data[].created_at":"number. Unix timestamp in SECONDS (converted by the API).","data[].updated_at":"number. Unix timestamp in seconds (equals created_at on most rows; not bumped by line edits).","data[].created_by_user_id":"int. User id of the creator. The dashboard allows bulk edit / add item only to this user while draft.","data[].created_by_user_name":"string. Creator's username (\"\" if unknown). Shown as \"Created by\".","data[].materials":"array. Every line of the count.","data[].materials[].id":"int. Count LINE id (inventory_materials_count.id) — pass as materials[].id to update_inventory_count_item. Not a material id.","data[].materials[].material_id":"int. Material id (get_material / list_materials).","data[].materials[].counted_quantity":"number. Physically counted quantity, storage unit (up to 4 decimals).","data[].materials[].expected_quantity":"number. System stock snapshot stored when the line was created (storage unit). Approval adjusts stock by counted − expected using THIS value.","data[].materials[].price":"number. Unit cost per storage unit (the dashboard stores the material's avg_price). Variance cost = (counted − expected) × price.","data[].materials[].name":"string. Material name snapshot taken when the line was added.","data[].materials[].unit":"string. Storage unit of the material (materials.unit) — the unit of the quantities (\"\" if the material is gone).","data[].materials[].ingredientunit":"string. Ingredient unit (e.g. Gram) used for the dashboard's second quantity box.","data[].materials[].conversionrate":"string. Ingredient units per storage unit, e.g. \"1000\" (VARCHAR, parse it; 1 when missing)."},"master_rules":["The dashboard detail page shows: status chip, branch, start_date at start_time, type, total items, created by, and a Variance Summary (discrepancies, over/under counts, Total Variance = Σ (counted − expected) units, Variance Cost = Σ (counted − expected) × price, Total Count Value = Σ counted × price). Table columns: name, unit (\"<unit> / <ingredientunit>\", \"1 unit = <conversionrate> ingredient\"), price, expected, counted, difference = counted − expected, diff. cost = difference × price. Expected/difference columns need the view_system_qty permission — do not reveal expected quantities to users who asked for a blind count.","Editing in the dashboard: while status is draft the CREATOR bulk-edits all lines (→ update_inventory_count_materials) and, for partial counts (type 0), adds materials (→ add_inventory_count_item); a per-line Edit (→ update_inventory_count_item) is offered for any non-approved count at the user's branch.","Lifecycle (dashboard): draft → \"Submit for Approval\" → pending → Approve / Reject / Send Back (→ draft); rejected → draft. APPROVE is what changes stock: for every line it posts counted_quantity − expected_quantity as an inventory ADJUSTMENT movement at the count's branch (price = line price; lines with 0 difference skipped), using the expected_quantity stored in the count, not live stock. The agent cannot submit, approve, reject or send back (change-status is not exposed) — after preparing a draft, tell the user to open Inventory → Inventory Count → the count and use Submit for Approval / Approve. The resulting adjustments are visible in list_inventory_movements (mov_type adjustment, reference \"Inventory count <name> - Approved (ID: <id>)\").","Quantities are in the material's STORAGE unit (materials.unit, e.g. Kilogram). The dashboard lets users type storage units plus ingredient units and sends counted = unit_qty + ingredient_qty / conversionrate; do the same conversion (conversionrate = ingredient units per storage unit, from list_materials / get_material).","expected_quantity is frozen at creation; if stock moved since (list_inventory_movements / get_material_stock), approval will still use the old snapshot — point this out when the count is old."],"example_request":{"id":156},"example":{"status":true,"message":"Inventory count found","data":[{"id":156,"start_date":"Mar 03, 2026","start_time":"01:16 PM","location_id":907,"name":"Default location - Mar 03, 2026 01:16 PM","type":2,"is_partial":0,"status":"approved","created_at":1772524002,"updated_at":1772524002,"created_by_user_id":4734,"created_by_user_name":"Nexus Cafe","materials":[{"id":1400,"material_id":119196,"counted_quantity":50,"expected_quantity":100,"price":100,"name":"Test Material","unit":"Kilogram","ingredientunit":"Gram","conversionrate":"1000"}]}]}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Inventory count id (list_inventory_counts → data[].id)."}},"required":["id"]},"module":"inventory_counts","safety":"read"},{"name":"list_recipes","description":"GET /api/recipe. Why: Recipe (BOM) catalog of the tenant: name, output yield_qty + unit, mode (phantom | stockable), last computed cost and brand tags. Recipes are tenant-wide (no branch filter). Use it to find recipe ids for get_recipe → update_recipe / delete_recipe, to pick STOCKABLE recipes for create_work_order (mode=stockable), or phantom recipes to link to products (create_product / update_product recipes[]). Ingredient lines, product links and branches are only in get_recipe. A recipe's prepared material (its stock item) is in list_materials (linked_recipe = recipe id). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_recipes\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/recipe","why":"Recipe (BOM) catalog of the tenant: name, output yield_qty + unit, mode (phantom | stockable), last computed cost and brand tags. Recipes are tenant-wide (no branch filter). Use it to find recipe ids for get_recipe → update_recipe / delete_recipe, to pick STOCKABLE recipes for create_work_order (mode=stockable), or phantom recipes to link to products (create_product / update_product recipes[]). Ingredient lines, product links and branches are only in get_recipe. A recipe's prepared material (its stock item) is in list_materials (linked_recipe = recipe id).","response":{"description":"200 → { message, data, pagination }. data = recipes of the tenant, ORDER BY id DESC, each with its last cost snapshot (recipe_cost) and brand tags. 500 → { message: \"Failed to fetch recipes\" }.","fields":{"message":"string. \"Recipes Fetched Successfully\".","data":"array. Recipes on this page.","data[].id":"int. Recipe id. Pass as `id` to get_recipe / update_recipe / delete_recipe and as recipes[].recipe_id to create_work_order.","data[].customer_id":"int. Tenant id. Always your own tenant; not needed.","data[].name":"string. Recipe name. The recipe's prepared material (list_materials, linked_recipe = this id) carries the same name.","data[].yield_qty":"number. Output of ONE batch of the recipe, in `unit` (e.g. 0.8 = the lines make 0.8 Kilogram). Recipe cost per unit = total_cost / yield_qty; a work order line qty is expressed in this unit.","data[].unit":"string. Output unit label (dashboard: the UOM name, e.g. \"Kilogram\", \"Pieces\"). Also the unit of the prepared material and of product links (portion_qty).","data[].version":"int. Always 1 (not incremented on update). Not needed.","data[].created_by":"int. User id that created the recipe. Informational.","data[].mode":"\"phantom\" | \"stockable\" | null. phantom = no stock of its own: selling a linked product (sync_invoice) deducts the ingredient materials. stockable = produced in advance by a work order into its prepared material's stock; sales/sub-recipe use deduct that prepared material. Only phantom recipes can get NEW product links. null = legacy row (treated as phantom by sales). Cannot be changed after creation.","data[].sku_no":"string | null. Auto SKU \"rcp-<customer_id>-<id>\" (master tenants).","data[].unit_id":"int | null. UOM id of `unit` (the dashboard's unit picker; no MCP units tool — copy it from another recipe or a material with the same unit, or send null).","data[].unit_cost":"number | null. Cost of 1 `unit` of output = total_cost / yield_qty, as computed at the last create/update (recipe_cost row). null = never costed (e.g. the cost step failed). Not live: material price changes after the save are not reflected here.","data[].total_cost":"number | null. Cost of one full batch = Σ lines qty × (material avg cost / conversionrate) / (effective yield / 100), at the last save.","data[].calculation_date":"string (ISO timestamp) | null. When unit_cost/total_cost were computed.","data[].material_count":"int | null. Number of ingredient lines that were costed.","data[].waste_pct":"number | null. Always 0 (not used). Not needed.","data[].notes":"null. Cost-note column, never set. Not needed.","data[].brands":"array. Brand tags of the recipe ([] = none).","data[].brands[].id":"int. Brand id (list_brands).","data[].brands[].name":"string. Brand name.","pagination":"object. Paging info.","pagination.total":"int. Number of recipes matching the filters (all pages).","pagination.page":"int. Page returned.","pagination.limit":"int. Page size used.","pagination.totalPages":"int. ceil(total / limit)."},"master_rules":["Dashboard Inventory → Recipes list sends page, limit (10 default; 5/25/50 selectable), search, mode (\"All\" = omitted, Stockable, Phantom) and brand_id (\"All Brands\" = omitted); its Location filter is NOT sent (it only drives export/import). A second call with limit 10000 feeds the totals. Columns shown: name, yield_qty, unit, mode, brand names.","To page through everything send limit=500 (or larger) — the MCP keeps the API default of 10.","unit_cost / total_cost are the snapshot saved with the recipe (null = never costed); for live costs recompute from get_recipe lines and current material prices (list_materials / get_material).","Work orders: the dashboard's recipe picker loads list_recipes mode=stockable limit=500 (optionally brand_id). Only those recipes should go into create_work_order.","Product links: products may only be linked to phantom recipes (create_product / update_product recipes[] or update_recipe products). get_product → recipes[] shows a product's links; get_recipe → linked_products shows a recipe's.","Each recipe owns one prepared material (list_materials: material_type \"rm\", linked_recipe = recipe id, same name) — that material holds the recipe's stock per branch and is how another recipe uses it as a sub-recipe ingredient (get_material_used_in_recipes)."],"example":{"message":"Recipes Fetched Successfully","data":[{"id":16779,"customer_id":437,"name":"Test","yield_qty":0.8,"unit":"Kilogram","version":1,"created_by":4734,"mode":"stockable","sku_no":"rcp-437-16779","unit_id":2,"unit_cost":50,"total_cost":50,"calculation_date":"2026-02-12T13:26:26.000Z","material_count":2,"waste_pct":0,"notes":null,"brands":[{"id":412,"name":"Nexus Cafe"},{"id":473,"name":"Test "}]}],"pagination":{"total":902,"page":1,"limit":1,"totalPages":902}}},"parameters":{"type":"object","properties":{"page":{"type":"number","description":"1-based page."},"limit":{"type":"number","description":"Page size."},"search":{"type":"string","description":"Case-insensitive substring of the recipe name (`%`/`_` act as wildcards)."},"mode":{"type":"string","enum":["phantom","stockable"],"description":"Exact mode filter."},"brand_id":{"type":"number","description":"Brand id (list_brands): only recipes tagged with this brand (brand_ids)."}},"required":[]},"module":"recipes","safety":"read"},{"name":"get_recipe","description":"GET /api/recipe/:id. Why: One recipe with its ingredient lines (material, qty in ingredient unit, yield override, material cost data), linked products (qty_used), brand tags and the branches of its prepared material. Always call it before update_recipe (update is a full replace) and before create_work_order (the work-order material lines are computed from these lines). Material ids → get_material / list_materials; product ids → get_product; branch ids → list_locations; brand ids → list_brands. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_recipe\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/recipe/:id","why":"One recipe with its ingredient lines (material, qty in ingredient unit, yield override, material cost data), linked products (qty_used), brand tags and the branches of its prepared material. Always call it before update_recipe (update is a full replace) and before create_work_order (the work-order material lines are computed from these lines). Material ids → get_material / list_materials; product ids → get_product; branch ids → list_locations; brand ids → list_brands.","response":{"description":"200 → { message, data: [ { <recipes row>, recipe_items, product_ids, linked_products, brand_ids, location_id } ] }. recipe_items = recipe_items rows joined to their material (only active materials of your tenant). 404 → { message: \"Recipe not found\" } (unknown id or another tenant's). 500 → { message: \"Failed to fetch recipe\" }.","fields":{"message":"string. \"Recipe Fetched Successfully\".","data":"array. Exactly one recipe.","data[0].id":"int. Recipe id. Pass as `id` to get_recipe / update_recipe / delete_recipe and as recipes[].recipe_id to create_work_order.","data[0].customer_id":"int. Tenant id. Always your own tenant; not needed.","data[0].name":"string. Recipe name. The recipe's prepared material (list_materials, linked_recipe = this id) carries the same name.","data[0].yield_qty":"number. Output of ONE batch of the recipe, in `unit` (e.g. 0.8 = the lines make 0.8 Kilogram). Recipe cost per unit = total_cost / yield_qty; a work order line qty is expressed in this unit.","data[0].unit":"string. Output unit label (dashboard: the UOM name, e.g. \"Kilogram\", \"Pieces\"). Also the unit of the prepared material and of product links (portion_qty).","data[0].version":"int. Always 1 (not incremented on update). Not needed.","data[0].created_by":"int. User id that created the recipe. Informational.","data[0].mode":"\"phantom\" | \"stockable\" | null. phantom = no stock of its own: selling a linked product (sync_invoice) deducts the ingredient materials. stockable = produced in advance by a work order into its prepared material's stock; sales/sub-recipe use deduct that prepared material. Only phantom recipes can get NEW product links. null = legacy row (treated as phantom by sales). Cannot be changed after creation.","data[0].sku_no":"string | null. Auto SKU \"rcp-<customer_id>-<id>\" (master tenants).","data[0].unit_id":"int | null. UOM id of `unit` (the dashboard's unit picker; no MCP units tool — copy it from another recipe or a material with the same unit, or send null).","data[0].recipe_items":"array. Ingredient lines — only lines whose material is still active in your tenant (lines of deactivated/deleted materials are hidden and are DROPPED if you save this list back).","data[0].recipe_items[].recipe_id":"int. This recipe id.","data[0].recipe_items[].material_id":"int. Ingredient material id (get_material / list_materials). A prepared material of another recipe (material_type \"rm\") makes this a sub-recipe line.","data[0].recipe_items[].qty":"number. Quantity of the ingredient in ONE batch, in the material's ingredient unit (`ingredientunit`), BEFORE yield loss. Stock actually consumed = qty / (effective_yield / 100).","data[0].recipe_items[].unit":"string. Unit label saved with the line (the dashboard saves the material's ingredientunit). Informational — costing and stock maths assume the ingredient unit.","data[0].recipe_items[].wastage_pct":"number. Legacy column, always 0; not used by costing (use yield_pct). Not needed.","data[0].recipe_items[].id":"int. recipe_items row id. Lines are deleted and re-inserted on every update, so ids change. Not needed.","data[0].recipe_items[].type":"\"mat\" | \"sr\". Set by the server from the material: \"sr\" = the material is another recipe's prepared material (sub-recipe), \"mat\" = raw material.","data[0].recipe_items[].unit_id":"int | null. UOM id stored on the line (null when not sent; the dashboard does not send it).","data[0].recipe_items[].yield_pct":"number | null. Line yield override in % (usable portion after prep; 80 = 20 % loss). null = inherit the material's yield_percentage (100 when unset).","data[0].recipe_items[].name":"string. Material name.","data[0].recipe_items[].ingredientunit":"string | null. The material's ingredient (recipe) unit, e.g. \"Gram\". `qty` is in this unit.","data[0].recipe_items[].conversionrate":"string. Ingredient units per storage unit (e.g. \"1000\" g per kg); comes back as a string. Cost per ingredient unit = avg_price / conversionrate.","data[0].recipe_items[].avg_price":"number. Material average cost per STORAGE unit (tenant-level materials.avg_price).","data[0].recipe_items[].price":"number. Material list price per storage unit (0 when unset). Informational.","data[0].recipe_items[].material_yield":"number | null. The material's default yield_percentage. In current code; NOT returned by dev1's build yet (compute from get_material).","data[0].recipe_items[].effective_yield":"number. yield_pct ?? material yield_percentage ?? 100 — the yield actually used for cost and stock. In current code; NOT returned by dev1's build yet (compute it as described).","data[0].product_ids":"int[]. Ids of products linked to this recipe (product_recipes).","data[0].linked_products":"array. The same links with qty_used. Resend as update_recipe `products` ([{ id, qty_used }]) or they are removed.","data[0].linked_products[].product_id":"int. Linked product id (get_product).","data[0].linked_products[].id":"int. Same as product_id.","data[0].linked_products[].name":"string. Product name (\"Product #<id>\" when the product row is gone).","data[0].linked_products[].qty_used":"number. How many whole recipes one sold unit of the product consumes (default 1). A sale deducts qty_used × every line's yield-adjusted qty (the recipe's yield_qty is not a divisor).","data[0].linked_products[].portion_qty":"number | null. Copy of the recipe's yield_qty at the time the link was saved. Informational.","data[0].linked_products[].unit":"string | null. Copy of the recipe's unit at link time. Informational.","data[0].brand_ids":"int[]. Brand ids (list_brands) the recipe is tagged with ([] = none). Resend on update_recipe or they are cleared.","data[0].location_id":"int[]. Branches (list_locations) where the recipe's prepared material exists (materials_locations rows = where it has stock/cost). [] when the prepared material is missing. Resend as update_recipe location_id."},"master_rules":["Dashboard recipe edit page (Inventory → Recipes → edit) loads this and pre-fills: name, yield_qty, unit (+ unit_id), mode (locked: \"Recipe mode cannot be changed after creation\"), locations, brands, ingredient lines (material, qty, yield) and linked products (qty_used).","Line maths (same as the dashboard and the server): effective_yield = yield_pct ?? material yield_percentage ?? 100; cost per ingredient unit = material avg_price / conversionrate; line cost = qty × that / (effective_yield / 100) (4 decimals); batch cost = Σ line costs; unit_cost = batch cost / yield_qty. The dashboard shows this in a cost-verification popup (POST /api/recipe/materials/costs, no MCP tool) before every save; do the same maths from get_material data when the user wants costs.","To edit: map recipe_items → { material_id, qty, unit, yield_pct } and linked_products → products [{ id, qty_used }], keep brand_ids, location_id, unit_id and mode, change what the user asked, and send everything to update_recipe.","Work orders: for each recipe line the dashboard computes the consumption line of create_work_order from these lines (see create_work_order). effective_yield / material_yield are not returned by dev1 yet — take the material's yield_percentage from get_material (100 when unset).","Sub-recipes: a line whose material is another recipe's prepared material (type \"sr\") uses that recipe. If the sub-recipe is stockable, sales deduct its prepared material; if phantom, sales recurse into its ingredients.","The dashboard product edit (Recipe tab) and modifier-item recipe links also read this; modifier-item recipe links use separate routes with no MCP tool."],"example":{"message":"Recipe Fetched Successfully","data":[{"id":16779,"customer_id":437,"name":"Test","yield_qty":0.8,"unit":"Kilogram","version":1,"created_by":4734,"mode":"stockable","sku_no":"rcp-437-16779","unit_id":2,"recipe_items":[{"recipe_id":16779,"material_id":119196,"qty":500,"unit":"Gram","wastage_pct":0,"id":988201,"type":"mat","unit_id":1,"yield_pct":null,"name":"Test Material","ingredientunit":"Gram","conversionrate":"1000","avg_price":22.0271,"price":0},{"recipe_id":16779,"material_id":119158,"qty":500,"unit":"Gram","wastage_pct":0,"id":988202,"type":"mat","unit_id":1,"yield_pct":null,"name":"Cherry Blosom","ingredientunit":"Gram","conversionrate":"1000","avg_price":0.3295,"price":0}],"product_ids":[257508],"linked_products":[{"product_id":257508,"id":257508,"name":"Mocha","qty_used":1,"portion_qty":0.8,"unit":"Kilogram"}],"brand_ids":[412,473],"location_id":[907,909,908,922,910,1101,1102,1100,1117]}]}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Recipe id (list_recipes data[].id)."}},"required":["id"]},"module":"recipes","safety":"read"},{"name":"create_material","description":"POST /api/materials. Why: Add a raw material (metadata only): name, storage unit (unit_id) and ingredient unit + conversionrate for recipes, costing method and cost, branches (location_id; one stock row per branch at 0), suppliers with purchase prices, category and brands. Stock starts at 0 everywhere — receive it with create_draft_purchase_order / create_stock_transfer; then use the id in recipes (create_recipe), supplier catalogs and counts. Recipe-output materials are created by the recipe tools, not here. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_material\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/materials","why":"Add a raw material (metadata only): name, storage unit (unit_id) and ingredient unit + conversionrate for recipes, costing method and cost, branches (location_id; one stock row per branch at 0), suppliers with purchase prices, category and brands. Stock starts at 0 everywhere — receive it with create_draft_purchase_order / create_stock_transfer; then use the id in recipes (create_recipe), supplier catalogs and counts. Recipe-output materials are created by the recipe tools, not here.","response":{"description":"200 → [ { material }, { materials_locations } ]. material = the request body as the server saw it (costingmethod normalised, category_id validated, is_master_user added) + id, sku_no \"mat-<customer_id>-<id>\", mode \"stockable\", customer_id, location_id (int array actually used) and brand_ids (valid ids kept). It is NOT re-read — call get_material for stored values. materials_locations = the branch rows inserted. Errors: 400 { error: \"Material already exists\", message: \"A material with this name is already active\" }; 400 { error: \"Invalid storage: …\" } (unit_id/custom_unit_id missing or both); 400 { success: false, error: \"Invalid material category.\", code: \"INVALID_MATERIAL_CATEGORY\" }; 400 { success: false, error: \"Costing method '…' is not yet supported …\", code: \"COSTING_METHOD_NOT_SUPPORTED\" }; 500 { error: \"Material creation failed\" }. Not transactional: a 500 after the insert (e.g. bad supplier row) leaves a half-created material.","fields":{"[0].material":"object. Echo of the request body plus the server-set keys below — not a DB read.","[0].material.id":"int. New material id.","[0].material.sku_no":"string. \"mat-<customer_id>-<id>\".","[0].material.mode":"string. Always \"stockable\".","[0].material.customer_id":"int. Tenant id. Not needed.","[0].material.location_id":"int[]. Branch ids the material was created on.","[0].material.brand_ids":"int[]. Brand ids actually linked (unknown ids dropped).","[0].material.costingmethod":"string. Normalised \"average\" | \"fixed\".","[0].material.category_id":"int | null. Validated category id (present only when sent).","[0].material.is_master_user":"0 | 1. Server-set master-locations flag. Not needed.","[0].material.stock":"number. 0 (forced by MCP).","[0].material.name":"Echo of name.","[0].material.unit_id":"Echo of unit_id (absent when not sent).","[0].material.custom_unit_id":"Echo of custom_unit_id (absent when not sent).","[0].material.unit":"Echo of unit (absent when not sent).","[0].material.ingredientunit":"Echo of ingredientunit (absent when not sent).","[0].material.conversionrate":"Echo of conversionrate (absent when not sent).","[0].material.price":"Echo of price (absent when not sent).","[0].material.selling_price":"Echo of selling_price (absent when not sent).","[0].material.wholesale_price":"Echo of wholesale_price (absent when not sent).","[0].material.suppliers":"Echo of suppliers (absent when not sent).","[0].material.suppliers_prices":"Echo of suppliers_prices (absent when not sent).","[0].material.location_prices":"Echo of location_prices (absent when not sent).","[0].material.description":"Echo of description (absent when not sent).","[0].material.yield_percentage":"Echo of yield_percentage (absent when not sent).","[0].material.type":"Echo of type (absent when not sent).","[0].material.material_type":"Echo of material_type (absent when not sent).","[0].material.low_stock":"Echo of low_stock (absent when not sent).","[0].material.minimum_level":"Echo of minimum_level (absent when not sent).","[0].material.maximum_level":"Echo of maximum_level (absent when not sent).","[0].material.par_level":"Echo of par_level (absent when not sent).","[0].material.barcode":"Echo of barcode (absent when not sent).","[0].material.tax_id":"Echo of tax_id (absent when not sent).","[1].materials_locations":"array. Branch rows inserted (one per location_id; [] only for non-master tenants).","[1].materials_locations[].id":"int. Placeholder 0, 1, 2… — NOT the real row id (the server loses the insert id). Not needed.","[1].materials_locations[].material_id":"int. New material id.","[1].materials_locations[].location_id":"int. Branch id.","[1].materials_locations[].customer_id":"int. Tenant id. Not needed.","[1].materials_locations[].material_type":"string. Always \"mat\".","[1].materials_locations[].avg_cost":"number. Opening unit cost at that branch: location_prices row, else price (fixed) or 0 (average).","[1].materials_locations[].stock":"number. 0.","error":"string. Error only (400/500): reason, see description.","message":"string. Error only (400 duplicate name): \"A material with this name is already active\".","success":"false. Error only (400 category / costing errors).","code":"string. Error only: \"INVALID_MATERIAL_CATEGORY\" or \"COSTING_METHOD_NOT_SUPPORTED\"."},"example_request":{"name":"Whole Milk","unit_id":6,"unit":"Liter","ingredientunit":"Milliliter","conversionrate":1000,"costingmethod":"fixed","price":3.2,"location_id":[907,908],"location_prices":[{"location_id":907,"avg_cost":3.2},{"location_id":908,"avg_cost":3.4}],"suppliers_prices":[{"supplier_id":970,"purchase_price":3.1}],"category_id":3,"yield_percentage":100},"example":[{"material":{"name":"Whole Milk","unit_id":6,"unit":"Liter","ingredientunit":"Milliliter","conversionrate":1000,"costingmethod":"fixed","price":3.2,"location_id":[907,908],"location_prices":[{"location_id":907,"avg_cost":3.2},{"location_id":908,"avg_cost":3.4}],"suppliers_prices":[{"supplier_id":970,"purchase_price":3.1}],"category_id":3,"yield_percentage":100,"stock":0,"is_master_user":1,"id":121001,"sku_no":"mat-437-121001","mode":"stockable","customer_id":437,"brand_ids":[]}},{"materials_locations":[{"id":0,"material_id":121001,"location_id":907,"customer_id":437,"material_type":"mat","avg_cost":3.2,"stock":0},{"id":1,"material_id":121001,"location_id":908,"customer_id":437,"material_type":"mat","avg_cost":3.4,"stock":0}]}],"master_rules":["Check list_materials {k: name, is_active: \"1\"} first — an active material with the same name (case-insensitive) → 400. A deactivated one with that name does not block (a NEW id is created; deactivated materials cannot be reactivated).","Storage unit: send exactly one of unit_id (standard) or custom_unit_id (customer unit) plus the matching unit label; ingredientunit + conversionrate (ingredient units per storage unit, e.g. kg → g = 1000; 1 when equal). Recipes use the ingredient unit; stock, prices and purchase orders use the storage unit.","Dashboard form (Inventory → Materials → Add): required Name, Storage Unit, Ingredient Unit, Conversion Rate (auto-filled from the unit-conversion table), Yield % (default 100, ≥ 0.01) and at least one Location; Cost Price is editable (and required, ≥ 0) only for Fixed costing — Average sends price \"\" (stored 0) and no location costs. It sends type \"raw\", category_id (or null), brand_ids, suppliers + suppliers_prices (ticked suppliers, price default 0), location_id (ticked branches) and, for fixed costing, location_prices [{location_id, avg_cost}] where an empty/0 branch cost becomes price.","Master inventory: stock is per branch. Every branch in location_id gets its own stock row (0) and cost; a material not on a branch cannot be stocked, counted or sold-deducted there. Send location_id as an array.","After creating: link it to recipes (create_recipe / update_recipe ingredients) so product and modifier sales deduct it (sync_invoice), and to suppliers for purchasing (create_draft_purchase_order). Opening stock comes from receiving a purchase order or a transfer — never from this tool.","Tax (tax_id) is out of scope: omit it."],"caveats":["Each call leaks one DB connection on the server (getConnection without release); many creates in a row can exhaust the pool. Create materials one at a time and stop on errors.","is_prepared=\"0\" still marks the material prepared; omit the param instead.","materials_locations[].id in the response is a placeholder (0,1,2…)."]},"parameters":{"type":"object","properties":{"is_prepared":{"type":"string","enum":["1"],"description":"Any non-empty value (even \"0\") → materials.is_prepared = 1 (in-house prepared item)."},"name":{"type":"string","description":"Material name."},"description":{"type":"string","description":"Free text."},"unit_id":{"type":"number","description":"Standard storage unit id (units_of_measure; on dev1 1 Gram, 2 Kilogram, 3 Pound, 4 Ounce, 5 Milliliter, 6 Liter, 7 Gallon, 8 Fluid Ounce, 9 Pieces, 10 Dozen, 1…"},"custom_unit_id":{"type":"number","description":"Customer-defined storage unit id (customer_uom_conversions; copy from a material that uses it)."},"unit":{"type":"string","description":"Storage unit display label stored in materials.unit, e.g."},"ingredientunit":{"type":"string","description":"Ingredient (recipe) unit label, e.g."},"conversionrate":{"type":"string","description":"Ingredient units per 1 storage unit, e.g."},"costingmethod":{"type":"string","enum":["average","fixed"],"description":"\"average\" (default; cost follows purchases/receipts) or \"fixed\" (cost = price, per-branch override via location_prices)."},"price":{"type":"number","description":"Cost price per storage unit."},"selling_price":{"type":"number","description":"Retail price when the material is sold directly (0 = none)."},"wholesale_price":{"type":"number","description":"Wholesale price (0 = none)."},"low_stock":{"type":"number","description":"Low-stock alert threshold in the storage unit (0 = no alert; drives get_materials_stats.low_stock)."},"minimum_level":{"type":"number","description":"Minimum stock level (storage unit)."},"maximum_level":{"type":"number","description":"Maximum stock level."},"par_level":{"type":"number","description":"Par (target) stock level."},"barcode":{"type":"string","description":"Barcode."},"category_id":{"type":"number","description":"Material category id (list_material_categories; must exist and not be deleted, else 400 INVALID_MATERIAL_CATEGORY)."},"type":{"type":"string","description":"Legacy free-text kind: \"raw\" for normal materials (dashboard always sends raw on create and keeps the current value on edit)."},"material_type":{"type":"string","enum":["mat","rm"],"description":"\"mat\" = raw/purchased material (default)."},"yield_percentage":{"type":"number","description":"Usable yield % after prep, 0.01–100+ (100 = no loss)."},"tax_id":{"type":"number","description":"Tax id — tax groups are out of scope: omit on create; on update resend get_material.tax_id unchanged (omitting it sets the tax to NULL)."},"location_id":{"type":"array","items":{"type":"number","description":""},"description":"Branch ids to stock the material at (list_locations), as an ARRAY of ints (or csv string)."},"location_prices":{"type":"array","items":{"type":"object","properties":{"location_id":{"type":"number","description":"Branch id (must be in location_id)."},"avg_cost":{"type":"number","description":"Unit cost per storage unit at that branch (fixed costing)."}},"required":["location_id"],"description":""},"description":"Per-branch opening unit cost."},"suppliers":{"type":"array","items":{"type":"number","description":""},"description":"Supplier ids (list_suppliers) to link."},"suppliers_prices":{"type":"array","items":{"type":"object","properties":{"supplier_id":{"type":"number","description":"Supplier id (list_suppliers)."},"purchase_price":{"type":"number","description":"Purchase price per storage unit from that supplier (default 0)."}},"required":["supplier_id"],"description":""},"description":"Per-supplier purchase prices; also links those suppliers."},"brand_ids":{"type":"array","items":{"type":"number","description":""},"description":"Brand ids (list_brands) the material is limited to; unknown ids are dropped."},"ingredient_unit_id":{"type":"number","description":"Standard unit id of the ingredient unit (units_of_measure)."}},"required":["name"]},"module":"materials","safety":"write"},{"name":"update_material","description":"PUT /api/materials/:id. Why: Edit a material's metadata — name, units/conversion, costing method and cost (fixed: per-branch costs via location_prices), selling/wholesale price, stock levels, barcode, category, yield, branches, suppliers + purchase prices, brands. It is a FULL overwrite: load get_material {id, loc} first and send that record back with your changes (including stock, location_id, unit_id, tax_id, suppliers_prices, brand_ids = brands[].id). Never use it to change on-hand stock — use counts, wastage, transfers or purchase orders. FULL OVERWRITE: every field you omit (except category_id) is set to NULL/default, omitted suppliers/brands are unlinked, and branches missing from location_id lose their stock rows. Always start from get_material. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_material\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/materials/:id","why":"Edit a material's metadata — name, units/conversion, costing method and cost (fixed: per-branch costs via location_prices), selling/wholesale price, stock levels, barcode, category, yield, branches, suppliers + purchase prices, brands. It is a FULL overwrite: load get_material {id, loc} first and send that record back with your changes (including stock, location_id, unit_id, tax_id, suppliers_prices, brand_ids = brands[].id). Never use it to change on-hand stock — use counts, wastage, transfers or purchase orders.","response":{"description":"200 → the request body as the server saw it plus customer_id, location_id null (master tenants), costingmethod normalised and brand_ids (valid ids actually linked). NOT a DB read — call get_material to see stored values. Unknown id → 200 with nothing updated. Errors: 400 \"barcode already linked with other material\" (a bare JSON string, not an object); 400 { error: \"Invalid storage: …\" }; 400 { success: false, error: \"Invalid material category.\", code: \"INVALID_MATERIAL_CATEGORY\" }; 400 { success: false, error, code: \"COSTING_METHOD_NOT_SUPPORTED\" }; 500 { error: \"Material update failed\" } (e.g. body id missing). Not transactional: branch rows are added/removed BEFORE the storage-unit check, so a 400 \"Invalid storage\" can still have deleted branches missing from location_id.","fields":{"id":"int. Echo of the body id.","customer_id":"int. Tenant id added by the server. Not needed.","location_id":"null. Master tenants store branch links in materials_locations, so the echo shows null — call get_material for the branch list.","costingmethod":"string. Normalised \"average\" | \"fixed\" (absent when not sent).","brand_ids":"int[]. Brand ids actually linked after the save (unknown ids dropped; [] = all brands).","name":"Echo of name.","stock":"Echo of stock.","description":"Echo of description (absent when not sent).","price":"Echo of price (absent when not sent).","selling_price":"Echo of selling_price (absent when not sent).","wholesale_price":"Echo of wholesale_price (absent when not sent).","unit":"Echo of unit (absent when not sent).","unit_id":"Echo of unit_id (absent when not sent).","custom_unit_id":"Echo of custom_unit_id (absent when not sent).","ingredientunit":"Echo of ingredientunit (absent when not sent).","conversionrate":"Echo of conversionrate (absent when not sent).","low_stock":"Echo of low_stock (absent when not sent).","minimum_level":"Echo of minimum_level (absent when not sent).","maximum_level":"Echo of maximum_level (absent when not sent).","par_level":"Echo of par_level (absent when not sent).","barcode":"Echo of barcode (absent when not sent).","category_id":"Echo of category_id as sent (absent when not sent).","type":"Echo of type (absent when not sent).","material_type":"Echo of material_type as sent (absent when not sent; the stored default may differ).","yield_percentage":"Echo of yield_percentage as sent (absent when not sent).","tax_id":"Echo of tax_id (absent when not sent).","location_prices":"Echo of location_prices (absent when not sent).","suppliers":"Echo of suppliers (absent when not sent).","suppliers_prices":"Echo of suppliers_prices (absent when not sent).","error":"string. Error only: \"Invalid storage: …\", \"Invalid material category.\", costing-method text or \"Material update failed\".","success":"false. Error only (category / costing errors).","code":"string. Error only: \"INVALID_MATERIAL_CATEGORY\" or \"COSTING_METHOD_NOT_SUPPORTED\"."},"example_request":{"id":119158,"name":"Cherry Blossom","description":"Cherry","price":0,"selling_price":0,"wholesale_price":0,"stock":-12762188.785166,"unit":"Kilogram","unit_id":2,"custom_unit_id":null,"ingredientunit":"Gram","conversionrate":"1000","costingmethod":"average","low_stock":0,"minimum_level":"0","maximum_level":"0","par_level":"0","barcode":"","type":"raw","material_type":"mat","category_id":2,"yield_percentage":100,"tax_id":null,"location_id":[907,908,909,910,922],"suppliers":[970],"suppliers_prices":[{"supplier_id":970,"purchase_price":1.1092}],"brand_ids":[412,473]},"example":{"id":119158,"name":"Cherry Blossom","description":"Cherry","price":0,"selling_price":0,"wholesale_price":0,"stock":-12762188.785166,"unit":"Kilogram","unit_id":2,"custom_unit_id":null,"ingredientunit":"Gram","conversionrate":"1000","costingmethod":"average","low_stock":0,"minimum_level":"0","maximum_level":"0","par_level":"0","barcode":"","type":"raw","material_type":"mat","category_id":2,"yield_percentage":100,"tax_id":null,"location_id":null,"suppliers":[970],"suppliers_prices":[{"supplier_id":970,"purchase_price":1.1092}],"brand_ids":[412,473],"customer_id":437},"master_rules":["Always: get_material {id, loc} → copy every returned field → change what you need → send it with id, brand_ids = brands[].id and location_id as the full branch ARRAY. This is exactly what the dashboard edit page does (it spreads the loaded material into the form and saves the whole form plus id).","Everything omitted is overwritten: name/description/price/units/levels/barcode/type/tax → NULL, costingmethod → average, material_type → mat, yield_percentage → 100, suppliers → unlinked, brand_ids → cleared. Only category_id is kept when omitted.","Branches (master inventory): location_id is the complete set. Adding a branch creates its row with stock 0 (cost = location_prices/price for fixed, 0 for average). REMOVING a branch deletes its row — its stock and cost are lost without any movement; move/zero stock first (create_stock_transfer or a count), and never send a bare number or an empty list.","Cost: average costing → avg_cost is transaction-driven; don't send location_prices, keep price as loaded. Fixed costing → price is the default cost and location_prices [{location_id, avg_cost}] sets each branch's cost (the dashboard sends every ticked branch; an empty/0 branch cost is replaced by price). Changing costingmethod does not recompute past costs.","Stock: pass get_material.stock unchanged (legacy column). On-hand quantities change only via purchase orders, transfers, counts, wastage and work orders.","Units: keep exactly one of unit_id / custom_unit_id. Changing units or conversionrate re-scales every recipe that uses the material (recipe qty is in ingredientunit) — check get_material_used_in_recipes first.","is_active and mode cannot be changed here (deactivate with delete_material; the dashboard's Mode & Posting save sends mode but the server ignores it)."],"caveats":["Body id is what gets updated; MCP copies the path id into it. An unknown id returns 200 but may insert orphan branch rows.","A branch-scoped MCP key clamps location_id to its branches → the material is removed from every other branch. Use an unscoped key for multi-branch materials.","The dashboard detail page's Save & Activate spreads get_material (brands objects, no brand_ids) → it clears the material's brand links; don't copy that pattern."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Material id (list_materials)."},"name":{"type":"string","description":"Material name."},"description":{"type":"string","description":"Free text."},"unit_id":{"type":"number","description":"Standard storage unit id (units_of_measure; on dev1 1 Gram, 2 Kilogram, 3 Pound, 4 Ounce, 5 Milliliter, 6 Liter, 7 Gallon, 8 Fluid Ounce, 9 Pieces, 10 Dozen, 1…"},"custom_unit_id":{"type":"number","description":"Customer-defined storage unit id (customer_uom_conversions; copy from a material that uses it)."},"unit":{"type":"string","description":"Storage unit display label stored in materials.unit, e.g."},"ingredientunit":{"type":"string","description":"Ingredient (recipe) unit label, e.g."},"conversionrate":{"type":"string","description":"Ingredient units per 1 storage unit, e.g."},"costingmethod":{"type":"string","enum":["average","fixed"],"description":"\"average\" (default; cost follows purchases/receipts) or \"fixed\" (cost = price, per-branch override via location_prices)."},"price":{"type":"number","description":"Cost price per storage unit."},"selling_price":{"type":"number","description":"Retail price when the material is sold directly (0 = none)."},"wholesale_price":{"type":"number","description":"Wholesale price (0 = none)."},"low_stock":{"type":"number","description":"Low-stock alert threshold in the storage unit (0 = no alert; drives get_materials_stats.low_stock)."},"minimum_level":{"type":"number","description":"Minimum stock level (storage unit)."},"maximum_level":{"type":"number","description":"Maximum stock level."},"par_level":{"type":"number","description":"Par (target) stock level."},"barcode":{"type":"string","description":"Barcode."},"category_id":{"type":"number","description":"Material category id (list_material_categories; must exist and not be deleted, else 400 INVALID_MATERIAL_CATEGORY)."},"type":{"type":"string","description":"Legacy free-text kind: \"raw\" for normal materials (dashboard always sends raw on create and keeps the current value on edit)."},"material_type":{"type":"string","enum":["mat","rm"],"description":"\"mat\" = raw/purchased material (default)."},"yield_percentage":{"type":"number","description":"Usable yield % after prep, 0.01–100+ (100 = no loss)."},"tax_id":{"type":"number","description":"Tax id — tax groups are out of scope: omit on create; on update resend get_material.tax_id unchanged (omitting it sets the tax to NULL)."},"location_id":{"type":"array","items":{"type":"number","description":""},"description":"REQUIRED on update: the COMPLETE list of branch ids the material must stay on (get_material.location_id, plus/minus the branches you change)."},"location_prices":{"type":"array","items":{"type":"object","properties":{"location_id":{"type":"number","description":"Branch id (must be in location_id)."},"avg_cost":{"type":"number","description":"Unit cost per storage unit at that branch (fixed costing)."}},"required":["location_id"],"description":""},"description":"Fixed costing only (ignored for average): per-branch unit cost."},"suppliers":{"type":"array","items":{"type":"number","description":""},"description":"Supplier ids (list_suppliers)."},"suppliers_prices":{"type":"array","items":{"type":"object","properties":{"supplier_id":{"type":"number","description":"Supplier id (list_suppliers)."},"purchase_price":{"type":"number","description":"Purchase price per storage unit from that supplier (default 0)."}},"required":["supplier_id"],"description":""},"description":"Per-supplier purchase prices."},"brand_ids":{"type":"array","items":{"type":"number","description":""},"description":"Brand ids (list_brands) the material is limited to; unknown ids are dropped."},"stock":{"type":"number","description":"Pass get_material.stock back unchanged."}},"required":["id","name","location_id","stock"]},"module":"materials","safety":"write"},{"name":"delete_material","description":"DELETE /api/materials/:id. Why: Deactivate a material: is_active = 0 and ALL its branch rows (stock + cost) are deleted, no inventory movement is posted and recipe links stay. Use it only for materials that are no longer bought or used — check get_material_used_in_recipes (and product BOMs) first, and zero stock through counts/wastage/transfers if you need an audit trail. Cannot be undone through the API (update_material cannot reactivate; a new create_material makes a new id). Never use DELETE /materials/all. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_material\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/materials/:id","why":"Deactivate a material: is_active = 0 and ALL its branch rows (stock + cost) are deleted, no inventory movement is posted and recipe links stay. Use it only for materials that are no longer bought or used — check get_material_used_in_recipes (and product BOMs) first, and zero stock through counts/wastage/transfers if you need an audit trail. Cannot be undone through the API (update_material cannot reactivate; a new create_material makes a new id). Never use DELETE /materials/all.","response":{"description":"200 → { status: true, message: \"Material deactivated and location mappings removed\" } — also for an unknown id (nothing is checked). 400 → { status: false, message: \"Valid material ID is required\" } (id 0 / non-numeric). 500 → { status: false, message: \"Failed to delete material\" } (error text only in development).","fields":{"status":"boolean. true on success; false on the 400/500 errors.","message":"string. \"Material deactivated and location mappings removed\", or the error reason.","error":"string. Error only (500, development builds): database error text."},"example_request":{"id":119157},"example":{"status":true,"message":"Material deactivated and location mappings removed"},"master_rules":["The dashboard deletes from the materials list (row menu → confirm; or tick several → \"Delete selected\" → one call per id) and from the edit page (\"Delete material?\" confirm). It shows \"Material deleted successfully\" and refetches; the material then only appears with list_materials is_active=0.","Before deleting: get_material_used_in_recipes (recipes keep pointing at the deactivated material — sales of products/modifiers using those recipes then deduct a material with no branch rows), get_material for remaining stock per branch.","The branch stock rows are deleted outright: on-hand quantity and cost vanish without a movement. If stock matters, record it first (create_wastage_draft / create_inventory_count / create_stock_transfer) and let a user approve it.","Recipe-output materials (rm) belong to their recipe (linked_recipe); remove the recipe with the recipe tools instead of deleting the material."],"caveats":["No tenant or usage check on the server; always confirm the id with get_material first."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Material id (list_materials)."}},"required":["id"]},"module":"materials","safety":"destructive"},{"name":"get_material_category","description":"GET /api/inventory/categories/:id. Why: One material category (name, parent, description, image) — load it before update_material_category or to resolve a material's category_id to a name. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_material_category\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/inventory/categories/:id","why":"One material category (name, parent, description, image) — load it before update_material_category or to resolve a material's category_id to a name.","response":{"description":"200 → { success: true, data: { id, name, description, parent_id, customer_id, color, image, is_deleted } }. 404 → { success: false, error: \"Material category not found.\", code: \"CATEGORY_NOT_FOUND\" }. 500 → { success: false, error }.","fields":{"success":"boolean. true on success; false on errors.","data.id":"int. Material category id — pass as category_id to create_material / update_material, as cat to list_materials, as id to get/update/delete_material_category, as parent_id for a subcategory.","data.name":"string. Category name (unique among non-deleted siblings, case-insensitive).","data.description":"string | null. Description (\"\" = none).","data.parent_id":"int | null. Parent category id; null = top level. Max depth 2 (top level → subcategory).","data.customer_id":"int. Tenant id. Not needed.","data.color":"string. Colour string (\"\" = none). Not used by the dashboard.","data.image":"string. Image path/URL as stored (\"\" = none); relative paths are served under <base>/api/uploads/.","data.is_deleted":"0. Always 0 — deleted categories are filtered out.","error":"string. Error only: \"Material category not found.\" (404: unknown, deleted or another tenant's) or DB error text (500).","code":"string. Error only: \"CATEGORY_NOT_FOUND\"."},"example_request":{"id":3},"example":{"success":true,"data":{"id":3,"name":"Dairy","description":"","parent_id":2,"customer_id":437,"color":"","image":"","is_deleted":0}},"master_rules":["The dashboard category edit page loads it by id; the title is \"Edit Subcategory\" when parent_id is set, else \"Edit Category\".","Materials in it: list_materials {cat: \"<id>\"}."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Material category id (list_material_categories)."}},"required":["id"]},"module":"material_categories","safety":"read"},{"name":"create_material_category","description":"POST /api/inventory/categories. Why: Add a material category or subcategory (taxonomy only; no stock impact) so materials can be grouped via create_material / update_material category_id and filtered in list_materials (cat). Two levels max: a subcategory's parent must be a top-level category. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_material_category\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/inventory/categories","why":"Add a material category or subcategory (taxonomy only; no stock impact) so materials can be grouped via create_material / update_material category_id and filtered in list_materials (cat). Two levels max: a subcategory's parent must be a top-level category.","response":{"description":"201 → { success: true, message: \"Material category created successfully\", data: category }. 400 → { success: false, error: \"Category name is required.\", code: \"CATEGORY_NAME_REQUIRED\" } | { …\"Parent category not found or inactive.\", code: \"CATEGORY_PARENT_INVALID\" } | { …\"Category hierarchy cannot exceed 2 levels.\", code: \"CATEGORY_MAX_DEPTH_EXCEEDED\" }. 409 → { success: false, error: \"A category with this name already exists at this level.\", code: \"CATEGORY_NAME_EXISTS\" }. 500 → { success: false, error }. Runs in a transaction (nothing saved on error).","fields":{"success":"boolean. true on success; false on errors.","message":"string. Success only: the success message.","data":"object. The saved category, re-read from the DB.","data.id":"int. Material category id — pass as category_id to create_material / update_material, as cat to list_materials, as id to get/update/delete_material_category, as parent_id for a subcategory.","data.name":"string. Category name (unique among non-deleted siblings, case-insensitive).","data.description":"string | null. Description (\"\" = none).","data.parent_id":"int | null. Parent category id; null = top level. Max depth 2 (top level → subcategory).","data.customer_id":"int. Tenant id. Not needed.","data.color":"string. Colour string (\"\" = none). Not used by the dashboard.","data.image":"string. Image path/URL as stored (\"\" = none); relative paths are served under <base>/api/uploads/.","data.is_deleted":"0. Always 0 — deleted categories are filtered out.","error":"string. Error only: reason (see description).","code":"string. Error only: CATEGORY_NAME_REQUIRED | CATEGORY_PARENT_INVALID | CATEGORY_MAX_DEPTH_EXCEEDED | CATEGORY_NAME_EXISTS | CATEGORY_CYCLE_DETECTED | CATEGORY_NOT_FOUND (absent on 500 DB errors)."},"example_request":{"name":"Cheese","parent_id":2},"example":{"success":true,"message":"Material category created successfully","data":{"id":7,"name":"Cheese","description":"","parent_id":2,"customer_id":437,"color":"","image":"","is_deleted":0}},"master_rules":["Dashboard (Material Categories → Add / Add subcategory): Name (required, trimmed), Description, Parent category (only top-level categories offered; \"None\" = top level; preset from the row's \"Add subcategory\"), Category Image upload. It sends { name, description, image, parent_id (number or null) } and never color.","Check list_material_categories {format: \"flat\", k: name} first to avoid the 409 and to reuse an existing category."]},"parameters":{"type":"object","properties":{"name":{"type":"string","description":"Category name, trimmed."},"description":{"type":"string","description":"Free-text description."},"parent_id":{"type":"number","description":"Parent category id for a subcategory (must be an existing non-deleted TOP-LEVEL category, else 400 CATEGORY_PARENT_INVALID / CATEGORY_MAX_DEPTH_EXCEEDED)."},"image":{"type":"string","description":"Image path or URL stored verbatim (upload URL from the dashboard uploader)."},"color":{"type":"string","description":"Colour string, stored verbatim (not shown by the dashboard)."}},"required":["name"]},"module":"material_categories","safety":"write"},{"name":"update_material_category","description":"PUT /api/inventory/categories/:id. Why: Rename, re-describe, change the image of, or re-parent a material category (taxonomy only; materials keep their category_id). Partial update: only the keys you send change. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_material_category\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/inventory/categories/:id","why":"Rename, re-describe, change the image of, or re-parent a material category (taxonomy only; materials keep their category_id). Partial update: only the keys you send change.","response":{"description":"200 → { success: true, message: \"Material category updated successfully\", data: category (re-read) }. 404 → { success: false, error: \"Material category not found.\" | \"Material category not found or no changes applied.\" (body had none of the five keys), code: \"CATEGORY_NOT_FOUND\" }. 400 → CATEGORY_NAME_REQUIRED | CATEGORY_PARENT_INVALID | CATEGORY_CYCLE_DETECTED (\"A category cannot be its own parent.\" / \"Cannot set parent: would create a cycle in the category tree.\") | CATEGORY_MAX_DEPTH_EXCEEDED. 409 → CATEGORY_NAME_EXISTS. 500 → { success: false, error } (e.g. color/image null). Transactional.","fields":{"success":"boolean. true on success; false on errors.","message":"string. Success only: the success message.","data":"object. The saved category, re-read from the DB.","data.id":"int. Material category id — pass as category_id to create_material / update_material, as cat to list_materials, as id to get/update/delete_material_category, as parent_id for a subcategory.","data.name":"string. Category name (unique among non-deleted siblings, case-insensitive).","data.description":"string | null. Description (\"\" = none).","data.parent_id":"int | null. Parent category id; null = top level. Max depth 2 (top level → subcategory).","data.customer_id":"int. Tenant id. Not needed.","data.color":"string. Colour string (\"\" = none). Not used by the dashboard.","data.image":"string. Image path/URL as stored (\"\" = none); relative paths are served under <base>/api/uploads/.","data.is_deleted":"0. Always 0 — deleted categories are filtered out.","error":"string. Error only: reason (see description).","code":"string. Error only: CATEGORY_NAME_REQUIRED | CATEGORY_PARENT_INVALID | CATEGORY_MAX_DEPTH_EXCEEDED | CATEGORY_NAME_EXISTS | CATEGORY_CYCLE_DETECTED | CATEGORY_NOT_FOUND (absent on 500 DB errors)."},"example_request":{"id":3,"name":"Dairy & Eggs"},"example":{"success":true,"message":"Material category updated successfully","data":{"id":3,"name":"Dairy & Eggs","description":"","parent_id":2,"customer_id":437,"color":"","image":"","is_deleted":0}},"master_rules":["The dashboard edit form sends { name, description, image, parent_id } every time (parent select disabled for subcategories, so their parent is resent unchanged). Send only what changes, or the same four keys from get_material_category.","Moving a category that has subcategories under another category is not blocked by the server but breaks the 2-level rule — only re-parent categories without children (check list_material_categories format=flat for rows with parent_id = id)."],"caveats":["Sending an empty body returns 404 \"not found or no changes applied\" even though the category exists."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Material category id (list_material_categories)."},"name":{"type":"string","description":"New name, trimmed (blank → 400)."},"description":{"type":"string","description":"Free-text description."},"parent_id":{"type":"number","description":"Move under this top-level category (null / \"\" / 0 → top level)."},"image":{"type":"string","description":"Image path or URL stored verbatim (upload URL from the dashboard uploader)."},"color":{"type":"string","description":"Colour string, stored verbatim (not shown by the dashboard)."}},"required":["id"]},"module":"material_categories","safety":"write"},{"name":"delete_material_category","description":"DELETE /api/inventory/categories/:id. Why: Soft-delete an EMPTY material category (no active materials assigned and no subcategories). Move materials first with update_material (category_id) and delete subcategories first. Taxonomy only; no stock impact. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_material_category\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/inventory/categories/:id","why":"Soft-delete an EMPTY material category (no active materials assigned and no subcategories). Move materials first with update_material (category_id) and delete subcategories first. Taxonomy only; no stock impact.","response":{"description":"200 → { success: true, message: \"Material category deleted successfully\", data: { id, deleted: true } }. 404 → { success: false, error: \"Material category not found.\", code: \"CATEGORY_NOT_FOUND\" }. 409 → { success: false, error: \"Cannot delete category while active materials are assigned to it.\", code: \"CATEGORY_IN_USE\" } | { …\"Cannot delete category while it has active subcategories.\", code: \"CATEGORY_HAS_CHILDREN\" }. 500 → { success: false, error }.","fields":{"success":"boolean. true on success; false on errors.","message":"string. Success only: \"Material category deleted successfully\".","data":"object. Success only.","data.id":"int. Deleted category id.","data.deleted":"true. Always true on success.","error":"string. Error only: reason (see description).","code":"string. Error only: CATEGORY_NOT_FOUND | CATEGORY_IN_USE | CATEGORY_HAS_CHILDREN (absent on 500)."},"example_request":{"id":6},"example":{"success":true,"message":"Material category deleted successfully","data":{"id":6,"deleted":true}},"master_rules":["The dashboard deletes from the Material Categories list row or the edit page after a confirm dialog and shows the server's error text (e.g. the 409 reasons).","To empty a category: list_materials {cat: \"<id>\"} → update_material each with another category_id (full record, see update_material); inactive materials don't block and keep the old id."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Material category id (list_material_categories)."}},"required":["id"]},"module":"material_categories","safety":"destructive"},{"name":"get_supplier","description":"GET /api/suppliers/:id. Why: Load one supplier record (all its columns) before update_supplier — the update overwrites every column, so you need the current values to send back — or to show its contact / payment details. Ids come from list_suppliers. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_supplier\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/suppliers/:id","why":"Load one supplier record (all its columns) before update_supplier — the update overwrites every column, so you need the current values to send back — or to show its contact / payment details. Ids come from list_suppliers.","response":{"description":"200 → [ supplier ] (array of one row, the raw suppliers columns; no counters — those are on list_suppliers). Unknown id → 200 []. DB error → 200 with an empty body.","fields":{"[]":"array. One element (the supplier) or empty when not found.","[].id":"int. Supplier id. Pass as `id` to get_supplier / update_supplier / delete_supplier, as `sup` to list_supplier_materials, as `sid` to list_supplier_invoices; purchases store it as table_id.","[].name":"string | null. Supplier display name (not unique — two suppliers may share a name).","[].description":"string | null. Free-text description.","[].customer_id":"int. Tenant id. Always your own tenant; not needed.","[].location_id":"int | null. Branch of the user who last created/saved the supplier (the API overwrites it with that user's home branch on every create/update). Suppliers are tenant-wide — this does not restrict where the supplier can be used.","[].address":"string | null. Postal address (max 200 chars). Copied into a PO as info_address.","[].logo":"string | null. Relative upload path of the logo image (\"\" = none).","[].tax_pct":"number | null. Supplier tax % as typed in the dashboard (2 decimals). Informational only — PO VAT is NOT computed from it. Tax is out of scope.","[].payments":"string | null. JSON string of accepted payment methods: \"{\\\"is_cash\\\":bool,\\\"is_credit\\\":bool,\\\"is_cheque\\\":bool}\". Parse it before reading.","[].trade_license":"string | null. Relative upload path of the trade-licence document (\"\" = none), not a licence number.","[].contact_name":"string | null. Contact person.","[].contact_email":"string | null. Contact email. PO emails go here when can_recieve_email = 1. Copied into a PO as info_email.","[].contact_phone":"string | null. Contact phone (max 45 chars). Copied into a PO as info_phone.","[].trn_certificate":"string | null. Relative upload path of the TRN certificate document (\"\" = none; max 45 chars).","[].can_recieve_email":"0 | 1. 1 = the supplier is emailed when a PO is created/confirmed (dashboard checkbox \"Receive purchase order emails\", default on)."},"example":[{"id":825,"name":"Grocery Supplier","description":"Grocery items","customer_id":437,"location_id":907,"address":"Warehouse 4, Al Quoz, Dubai","logo":"","tax_pct":5,"payments":"{\"is_cash\":true,\"is_credit\":false,\"is_cheque\":false}","trade_license":"","contact_name":"Sample Contact","contact_email":"orders@supplier.example","contact_phone":"+971500000001","trn_certificate":"","can_recieve_email":1}],"master_rules":["Read element [0]; an empty array means the id does not exist — do not treat it as an error with details.","Only use ids that list_suppliers returned for this tenant.","The dashboard edit drawer opens with this row: payments is parsed from its JSON string into three checkboxes (Cash / Credit / Cheque); logo, trade_license and trn_certificate are shown as uploaded files."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Supplier id from list_suppliers."}},"required":["id"]},"module":"suppliers","safety":"read"},{"name":"create_supplier","description":"POST /api/suppliers. Why: Add a vendor to the supplier directory (no PO, no stock). Check list_suppliers k=<name> first — the API does not reject duplicates. After creating, link the materials it sells in the dashboard (Suppliers → Link materials; no MCP tool) and read them back with list_supplier_materials. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_supplier\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/suppliers","why":"Add a vendor to the supplier directory (no PO, no stock). Check list_suppliers k=<name> first — the API does not reject duplicates. After creating, link the materials it sells in the dashboard (Suppliers → Link materials; no MCP tool) and read them back with list_supplier_materials.","response":{"description":"200 → the body you sent, echoed as JSON, with location_id (caller's home branch) and customer_id added. There is no id and no success flag. The API has no validation and swallows DB errors: a failed insert (e.g. a value longer than its column) still returns 200 with the same echo.","fields":{"name":"Echo of the `name` you sent (absent when not sent). Not re-read from the database.","description":"Echo of the `description` you sent (absent when not sent). Not re-read from the database.","address":"Echo of the `address` you sent (absent when not sent). Not re-read from the database.","tax_pct":"Echo of the `tax_pct` you sent (absent when not sent). Not re-read from the database.","contact_name":"Echo of the `contact_name` you sent (absent when not sent). Not re-read from the database.","contact_email":"Echo of the `contact_email` you sent (absent when not sent). Not re-read from the database.","contact_phone":"Echo of the `contact_phone` you sent (absent when not sent). Not re-read from the database.","payments":"Echo of the `payments` you sent (absent when not sent). Not re-read from the database.","logo":"Echo of the `logo` you sent (absent when not sent). Not re-read from the database.","trade_license":"Echo of the `trade_license` you sent (absent when not sent). Not re-read from the database.","trn_certificate":"Echo of the `trn_certificate` you sent (absent when not sent). Not re-read from the database.","can_recieve_email":"Echo of can_recieve_email as sent (absent when not sent); the stored value is normalized: 0/false → 0, anything else → 1.","id":"Echo of a body id if you sent one (update only; the dashboard does). Not a new id — create returns none.","location_id":"int. Caller's home branch, written to the supplier row.","customer_id":"int. Tenant id."},"example_request":{"name":"Fresh Farms Trading","description":"Vegetables and dairy","address":"Warehouse 12, Al Quoz, Dubai","tax_pct":5,"contact_name":"Sample Contact","contact_email":"orders@freshfarms.example","contact_phone":"+971500000002","payments":"{\"is_cash\":true,\"is_credit\":true,\"is_cheque\":false}","can_recieve_email":1,"logo":"","trade_license":"","trn_certificate":""},"example":{"name":"Fresh Farms Trading","description":"Vegetables and dairy","address":"Warehouse 12, Al Quoz, Dubai","tax_pct":5,"contact_name":"Sample Contact","contact_email":"orders@freshfarms.example","contact_phone":"+971500000002","payments":"{\"is_cash\":true,\"is_credit\":true,\"is_cheque\":false}","can_recieve_email":1,"logo":"","trade_license":"","trn_certificate":"","location_id":907,"customer_id":437},"master_rules":["Validate like the dashboard form before calling: name and contact_name required, tax_pct required and ≥ 0 (send 0 when the user has none), contact_email must be a valid email when given.","The dashboard sends: name, description, address, tax_pct, contact_name, contact_email, contact_phone, payments (JSON string of is_cash/is_credit/is_cheque, default all false), can_recieve_email (default 1), logo, trade_license, trn_certificate (upload paths, \"\" when nothing uploaded). Send the same set.","Check list_suppliers k=<name> first and warn the user about an existing supplier with the same name — the API happily creates duplicates.","The response has no id: confirm with list_suppliers k=<name> and take the newest matching id.","A 200 does not prove the row was saved (DB errors are swallowed) — always confirm with list_suppliers.","A new supplier has no materials: POs/drafts for it need material ids from list_materials; the supplier ↔ material price list is maintained in the dashboard (Link materials) and read with list_supplier_materials."]},"parameters":{"type":"object","properties":{"name":{"type":"string","description":"Supplier display name (max 300)."},"description":{"type":"string","description":"Free-text description (max 300)."},"address":{"type":"string","description":"Postal address (max 200)."},"tax_pct":{"type":"number","description":"Supplier tax % (≥ 0, 2 decimals)."},"contact_name":{"type":"string","description":"Contact person (max 200)."},"contact_email":{"type":"string","description":"Contact email (max 200); must be a valid email if given."},"contact_phone":{"type":"string","description":"Contact phone (max 45)."},"payments":{"type":"string","description":"Accepted payment methods as a JSON STRING (not an object): \"{\\\"is_cash\\\":true,\\\"is_credit\\\":false,\\\"is_cheque\\\":false}\"."},"can_recieve_email":{"type":"string","description":"1/true = email POs to contact_email, 0/false = don't."},"logo":{"type":"string","description":"Relative upload path of a logo image (from the dashboard file upload)."},"trade_license":{"type":"string","description":"Relative upload path of the trade-licence document (dashboard file upload, max 200)."},"trn_certificate":{"type":"string","description":"Relative upload path of the TRN certificate document (dashboard file upload, max 45)."}},"required":["name"]},"module":"suppliers","safety":"write"},{"name":"update_supplier","description":"PUT /api/suppliers/:id. Why: Change a supplier's directory fields (name, contacts, address, payment methods, PO-email flag). The API overwrites ALL columns, so load the row with get_supplier first and send every field back with your changes. Does not touch POs already issued (they keep their info_* snapshot). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_supplier\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/suppliers/:id","why":"Change a supplier's directory fields (name, contacts, address, payment methods, PO-email flag). The API overwrites ALL columns, so load the row with get_supplier first and send every field back with your changes. Does not touch POs already issued (they keep their info_* snapshot).","response":{"description":"200 → the body echoed back with location_id (caller's home branch — it overwrites the stored one) and customer_id. No success flag. Unknown id → still 200 (0 rows updated). DB errors are swallowed (still 200).","fields":{"name":"Echo of the `name` you sent (absent when not sent). Not re-read from the database.","description":"Echo of the `description` you sent (absent when not sent). Not re-read from the database.","address":"Echo of the `address` you sent (absent when not sent). Not re-read from the database.","tax_pct":"Echo of the `tax_pct` you sent (absent when not sent). Not re-read from the database.","contact_name":"Echo of the `contact_name` you sent (absent when not sent). Not re-read from the database.","contact_email":"Echo of the `contact_email` you sent (absent when not sent). Not re-read from the database.","contact_phone":"Echo of the `contact_phone` you sent (absent when not sent). Not re-read from the database.","payments":"Echo of the `payments` you sent (absent when not sent). Not re-read from the database.","logo":"Echo of the `logo` you sent (absent when not sent). Not re-read from the database.","trade_license":"Echo of the `trade_license` you sent (absent when not sent). Not re-read from the database.","trn_certificate":"Echo of the `trn_certificate` you sent (absent when not sent). Not re-read from the database.","can_recieve_email":"Echo of can_recieve_email as sent (absent when not sent); the stored value is normalized: 0/false → 0, anything else → 1.","id":"Echo of a body id if you sent one (update only; the dashboard does). Not a new id — create returns none.","location_id":"int. Caller's home branch, written to the supplier row.","customer_id":"int. Tenant id."},"example_request":{"id":825,"name":"Grocery Supplier LLC","description":"Grocery items","address":"Warehouse 4, Al Quoz, Dubai","tax_pct":5,"contact_name":"Sample Contact","contact_email":"orders@supplier.example","contact_phone":"+971500000001","payments":"{\"is_cash\":true,\"is_credit\":false,\"is_cheque\":false}","can_recieve_email":1,"logo":"","trade_license":"","trn_certificate":""},"example":{"name":"Grocery Supplier LLC","description":"Grocery items","address":"Warehouse 4, Al Quoz, Dubai","tax_pct":5,"contact_name":"Sample Contact","contact_email":"orders@supplier.example","contact_phone":"+971500000001","payments":"{\"is_cash\":true,\"is_credit\":false,\"is_cheque\":false}","can_recieve_email":1,"logo":"","trade_license":"","trn_certificate":"","location_id":907,"customer_id":437},"master_rules":["Always get_supplier(id) first and send ALL twelve fields (name, description, address, tax_pct, contact_name, contact_email, contact_phone, payments, can_recieve_email, logo, trade_license, trn_certificate) with only the requested ones changed — anything omitted is wiped to NULL. The dashboard edit drawer does exactly this (it resubmits the whole loaded row).","payments must stay a JSON string; keep the three keys is_cash / is_credit / is_cheque.","Same validation as the dashboard: name and contact_name required, tax_pct ≥ 0, valid contact_email.","Verify with get_supplier(id) afterwards — the echo does not prove the row changed (unknown ids and DB errors still return 200).","Existing POs keep the supplier data copied at creation (info_name / info_phone / info_email / info_address); only new POs and drafts pick up the change."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Supplier id from list_suppliers."},"name":{"type":"string","description":"Supplier display name."},"description":{"type":"string","description":"Free-text description (max 300)."},"address":{"type":"string","description":"Postal address (max 200)."},"tax_pct":{"type":"number","description":"Supplier tax % (≥ 0, 2 decimals)."},"contact_name":{"type":"string","description":"Contact person (max 200)."},"contact_email":{"type":"string","description":"Contact email (max 200); must be a valid email if given."},"contact_phone":{"type":"string","description":"Contact phone (max 45)."},"payments":{"type":"string","description":"Accepted payment methods as a JSON STRING (not an object): \"{\\\"is_cash\\\":true,\\\"is_credit\\\":false,\\\"is_cheque\\\":false}\"."},"can_recieve_email":{"type":"string","description":"1/true = email POs to contact_email, 0/false = don't."},"logo":{"type":"string","description":"Relative upload path of a logo image (from the dashboard file upload)."},"trade_license":{"type":"string","description":"Relative upload path of the trade-licence document (dashboard file upload, max 200)."},"trn_certificate":{"type":"string","description":"Relative upload path of the TRN certificate document (dashboard file upload, max 45)."}},"required":["id","name"]},"module":"suppliers","safety":"write"},{"name":"delete_supplier","description":"DELETE /api/suppliers/:id. Why: Permanently remove a supplier from the directory. Only for suppliers that are no longer used: check list_supplier_invoices sid=<id> (and list_suppliers total_orders / items_in_catalog) first — the API's own \"in use\" check only looks at legacy tables and will delete a supplier that still has purchase orders or material links. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_supplier\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/suppliers/:id","why":"Permanently remove a supplier from the directory. Only for suppliers that are no longer used: check list_supplier_invoices sid=<id> (and list_suppliers total_orders / items_in_catalog) first — the API's own \"in use\" check only looks at legacy tables and will delete a supplier that still has purchase orders or material links.","response":{"description":"200 → { status: true, message: \"supplier deleted successfully.\" }; 200 → { status: false, message: \"You can't delete this supplier. It's being used.\" } when rows exist in the legacy supplier_products table or legacy orders.order_by = supplier name. Unknown id → the handler throws before responding (request hangs / times out). There is no undo.","fields":{"status":"boolean. true = deleted; false = blocked by the legacy in-use check. Check it — both come back as HTTP 200.","message":"string. \"supplier deleted successfully.\" or \"You can't delete this supplier. It's being used.\""},"example":{"status":true,"message":"supplier deleted successfully."},"master_rules":["Before deleting, check list_suppliers → total_orders and items_in_catalog for this id, and list_supplier_invoices sid=<id>&is_archive=all. If it has POs or linked materials, tell the user and ask again: the API does NOT check purchases or material links and deleting would leave those POs pointing at a missing supplier.","Confirm with the user (name + id) first — the dashboard shows a confirmation dialog; the delete is permanent.","Only pass ids from list_suppliers: an unknown id makes the call hang instead of returning an error.","status false means nothing was deleted; report the message."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Supplier id from list_suppliers."}},"required":["id"]},"module":"suppliers","safety":"destructive"},{"name":"create_recipe","description":"POST /api/recipe. Why: Create a recipe (BOM): output yield_qty + unit, mode, ingredient lines (materials from list_materials, qty in their ingredient unit, optional yield %), branches, brand tags and — phantom only — product links. The server also creates the recipe's prepared material (list_materials, material_type \"rm\", linked_recipe = new id) at the chosen branches with the computed cost, and saves the cost snapshot. No stock moves: a phantom recipe deducts ingredients when a linked product is sold (sync_invoice); a stockable one is produced via create_work_order (completed in the dashboard). Send name, yield_qty, unit, unit_id, mode, recipe_items [{material_id, qty (ingredient unit), unit, yield_pct}] and location_id; products only for phantom recipes. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_recipe\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/recipe","why":"Create a recipe (BOM): output yield_qty + unit, mode, ingredient lines (materials from list_materials, qty in their ingredient unit, optional yield %), branches, brand tags and — phantom only — product links. The server also creates the recipe's prepared material (list_materials, material_type \"rm\", linked_recipe = new id) at the chosen branches with the computed cost, and saves the cost snapshot. No stock moves: a phantom recipe deducts ingredients when a linked product is sold (sync_invoice); a stockable one is produced via create_work_order (completed in the dashboard).","response":{"description":"200 → { message: \"Recipe Created Successfully\", data } (status 200, not 201). 400 → { message } for: missing yield_qty/unit (\"Yield quantity and unit are required fields\"), no branch on a master tenant (\"At least one location is required to create a recipe\"), branches outside the user's (\"Selected locations are not allowed for this user\"), stockable + product links (\"Stockable recipes cannot be linked to products. Only phantom recipes can be linked.\"). 500 → { message: \"Failed to create recipe\" } (e.g. missing name, recipe_items or a line unit — the recipe row may already exist: check list_recipes search=<name> before retrying). Failures in the cost / prepared-material step are swallowed: the recipe is saved but unit_cost stays null and the prepared material may be missing.","fields":{"message":"string. \"Recipe Created Successfully\" on 200; the reason on 400; \"Failed to create recipe\" on 500.","data":"object. The created recipe (same shape as get_recipe data[0] without location_id).","data.id":"int. Recipe id. Pass as `id` to get_recipe / update_recipe / delete_recipe and as recipes[].recipe_id to create_work_order.","data.customer_id":"int. Tenant id. Always your own tenant; not needed.","data.name":"string. Recipe name. The recipe's prepared material (list_materials, linked_recipe = this id) carries the same name.","data.yield_qty":"number. Output of ONE batch of the recipe, in `unit` (e.g. 0.8 = the lines make 0.8 Kilogram). Recipe cost per unit = total_cost / yield_qty; a work order line qty is expressed in this unit.","data.unit":"string. Output unit label (dashboard: the UOM name, e.g. \"Kilogram\", \"Pieces\"). Also the unit of the prepared material and of product links (portion_qty).","data.version":"int. Always 1 (not incremented on update). Not needed.","data.created_by":"int. User id that created the recipe. Informational.","data.mode":"\"phantom\" | \"stockable\" | null. phantom = no stock of its own: selling a linked product (sync_invoice) deducts the ingredient materials. stockable = produced in advance by a work order into its prepared material's stock; sales/sub-recipe use deduct that prepared material. Only phantom recipes can get NEW product links. null = legacy row (treated as phantom by sales). Cannot be changed after creation.","data.sku_no":"string | null. Auto SKU \"rcp-<customer_id>-<id>\" (master tenants).","data.unit_id":"int | null. UOM id of `unit` (the dashboard's unit picker; no MCP units tool — copy it from another recipe or a material with the same unit, or send null).","data.recipe_items":"array. Ingredient lines — only lines whose material is still active in your tenant (lines of deactivated/deleted materials are hidden and are DROPPED if you save this list back).","data.recipe_items[].recipe_id":"int. This recipe id.","data.recipe_items[].material_id":"int. Ingredient material id (get_material / list_materials). A prepared material of another recipe (material_type \"rm\") makes this a sub-recipe line.","data.recipe_items[].qty":"number. Quantity of the ingredient in ONE batch, in the material's ingredient unit (`ingredientunit`), BEFORE yield loss. Stock actually consumed = qty / (effective_yield / 100).","data.recipe_items[].unit":"string. Unit label saved with the line (the dashboard saves the material's ingredientunit). Informational — costing and stock maths assume the ingredient unit.","data.recipe_items[].wastage_pct":"number. Legacy column, always 0; not used by costing (use yield_pct). Not needed.","data.recipe_items[].id":"int. recipe_items row id. Lines are deleted and re-inserted on every update, so ids change. Not needed.","data.recipe_items[].type":"\"mat\" | \"sr\". Set by the server from the material: \"sr\" = the material is another recipe's prepared material (sub-recipe), \"mat\" = raw material.","data.recipe_items[].unit_id":"int | null. UOM id stored on the line (null when not sent; the dashboard does not send it).","data.recipe_items[].yield_pct":"number | null. Line yield override in % (usable portion after prep; 80 = 20 % loss). null = inherit the material's yield_percentage (100 when unset).","data.recipe_items[].name":"string. Material name.","data.recipe_items[].ingredientunit":"string | null. The material's ingredient (recipe) unit, e.g. \"Gram\". `qty` is in this unit.","data.recipe_items[].conversionrate":"string. Ingredient units per storage unit (e.g. \"1000\" g per kg); comes back as a string. Cost per ingredient unit = avg_price / conversionrate.","data.recipe_items[].avg_price":"number. Material average cost per STORAGE unit (tenant-level materials.avg_price).","data.recipe_items[].price":"number. Material list price per storage unit (0 when unset). Informational.","data.recipe_items[].material_yield":"number | null. The material's default yield_percentage. In current code; NOT returned by dev1's build yet (compute from get_material).","data.recipe_items[].effective_yield":"number. yield_pct ?? material yield_percentage ?? 100 — the yield actually used for cost and stock. In current code; NOT returned by dev1's build yet (compute it as described).","data.product_ids":"int[]. Ids of products linked to this recipe (product_recipes).","data.linked_products":"array. The same links with qty_used. Resend as update_recipe `products` ([{ id, qty_used }]) or they are removed.","data.linked_products[].product_id":"int. Linked product id (get_product).","data.linked_products[].id":"int. Same as product_id.","data.linked_products[].name":"string. Product name (\"Product #<id>\" when the product row is gone).","data.linked_products[].qty_used":"number. How many whole recipes one sold unit of the product consumes (default 1). A sale deducts qty_used × every line's yield-adjusted qty (the recipe's yield_qty is not a divisor).","data.linked_products[].portion_qty":"number | null. Copy of the recipe's yield_qty at the time the link was saved. Informational.","data.linked_products[].unit":"string | null. Copy of the recipe's unit at link time. Informational.","data.brand_ids":"int[]. Brand ids (list_brands) the recipe is tagged with ([] = none). Resend on update_recipe or they are cleared."},"master_rules":["Dashboard form (Inventory → Recipes → Create): Recipe Name (required), Yield Quantity (required, > 0), Unit (required; master inventory: pick a UOM → unit = uom_name, unit_id = its id), Locations (≥ 1 required on master; hint \"Recipe material stock and cost will only be created for the selected locations\"), Recipe brands (brands of the user's branch), Recipe Mode (Phantom default — \"Ingredients are processed when the recipe is sold\"; Stockable — produced in advance), Ingredients (≥ 1; materials picked from the branch's materials, qty > 0, unit = the material's ingredientunit, Yield % defaulting to the material's yield) and Link Products (phantom only; qty_used default 1). Ask the user for the same fields.","Body the dashboard sends: { name, yield_qty, unit, unit_id, mode, recipe_items: [{ material_id, qty, unit, yield_pct, unit_cost, total_cost, conversion_rate, price }], products: [{ id, qty_used }], product_ids: [ids], brand_ids, location_id: [ids] }. unit_cost / total_cost / conversion_rate / price are ignored by the server (it recomputes) — do not send them. yield_pct is null when it equals the material default.","Line maths (same as the dashboard and the server): effective_yield = yield_pct ?? material yield_percentage ?? 100; cost per ingredient unit = material avg_price / conversionrate; line cost = qty × that / (effective_yield / 100) (4 decimals); batch cost = Σ line costs; unit_cost = batch cost / yield_qty. Before saving, the dashboard shows these costs for confirmation — show the user the batch cost and unit cost (from get_material avg_price / conversionrate / yield_percentage) and ask to confirm.","Never add the recipe's own prepared material (or a material with the recipe's name) as an ingredient — the dashboard blocks it. Pick ingredient materials that exist at the recipe's branches (list_materials loc=<branch>).","Choose the mode with the user: phantom for things made to order (burger, latte) and linked to menu products; stockable for batch-prepared items kept in stock (sauces, dough) that work orders produce and that other recipes use as sub-recipe ingredients. It cannot be changed later.","Linking: link phantom recipes to products here (products) or from the product side (create_product / update_product recipes[] → get_product recipes[] / product_recipe_total). Each sale of the product then deducts qty_used × every ingredient line (yield-adjusted) at the selling branch (sync_invoice → list_inventory_movements). Modifier items link recipes through separate routes (no MCP tool). Products' product_cost is not refreshed by this call.","After creating, confirm with get_recipe (lines, links, location_id) and list_materials k=<name> (the prepared material, material_type \"rm\"). Creating the recipe does not create stock: a stockable recipe needs a work order (create_work_order) that the user starts and completes in the dashboard."],"example_request":{"name":"Tomato sauce","yield_qty":2,"unit":"Kilogram","unit_id":2,"mode":"stockable","recipe_items":[{"material_id":119196,"qty":1500,"unit":"Gram","yield_pct":null},{"material_id":119158,"qty":700,"unit":"Gram","yield_pct":90}],"location_id":[907],"brand_ids":[412],"products":[],"product_ids":[]},"example":{"message":"Recipe Created Successfully","data":{"id":17225,"customer_id":437,"name":"Tomato sauce","yield_qty":2,"unit":"Kilogram","version":1,"created_by":4734,"mode":"stockable","sku_no":"rcp-437-17225","unit_id":2,"recipe_items":[{"recipe_id":17225,"material_id":119196,"qty":1500,"unit":"Gram","wastage_pct":0,"id":1044600,"type":"mat","unit_id":null,"yield_pct":null,"name":"Test Material","ingredientunit":"Gram","conversionrate":"1000","avg_price":22.0271,"price":0,"material_yield":null,"effective_yield":100},{"recipe_id":17225,"material_id":119158,"qty":700,"unit":"Gram","wastage_pct":0,"id":1044601,"type":"mat","unit_id":null,"yield_pct":90,"name":"Cherry Blosom","ingredientunit":"Gram","conversionrate":"1000","avg_price":0.3295,"price":0,"material_yield":null,"effective_yield":90}],"product_ids":[],"linked_products":[],"brand_ids":[412]}}},"parameters":{"type":"object","properties":{"name":{"type":"string","description":"Recipe name."},"yield_qty":{"type":"number","description":"Output of one batch in `unit` (> 0)."},"unit":{"type":"string","description":"Output unit label; the dashboard sends the UOM name (e.g."},"unit_id":{"type":"number","description":"UOM id matching `unit` (no MCP units tool: copy from another recipe/material with the same unit, else null)."},"mode":{"type":"string","enum":["phantom","stockable"],"description":"phantom (default) = no own stock, sales of linked products deduct the ingredients; stockable = produced by work orders into the prepared material's stock, cann…"},"recipe_items":{"type":"array","items":{"type":"object","properties":{"material_id":{"type":"number","description":"Ingredient material id (list_materials / get_material)."},"qty":{"type":"number","description":"Quantity in ONE batch, in the material's ingredientunit (e.g."},"unit":{"type":"string","description":"The material's ingredientunit (get_material / list_materials)."},"yield_pct":{"type":"number","description":"Line yield override in % (0.01+; 80 = 20 % prep loss, >100 = expansion)."},"unit_id":{"type":"number","description":"UOM id stored on the line."}},"required":["material_id","qty","unit"],"description":""},"description":"Ingredient lines, at least one (the dashboard requires ≥ 1)."},"location_id":{"type":"array","items":{"type":"number","description":""},"description":"Branches (list_locations) where the prepared material (its stock/cost row) is created, as an array like the dashboard (the server also accepts a number or csv)."},"brand_ids":{"type":"array","items":{"type":"number","description":""},"description":"Brand ids (list_brands) to tag the recipe with; [] / omitted = none."},"products":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number","description":"Product id (list_products / get_product)."},"qty_used":{"type":"number","description":"Whole recipes consumed per product unit sold (> 0; default 1)."}},"required":["id"],"description":""},"description":"Product links, PHANTOM recipes only: [{ id, qty_used }]."},"product_ids":{"type":"array","items":{"type":"number","description":""},"description":"Product ids to link (each gets qty_used 1)."}},"required":["name","yield_qty","unit","recipe_items","location_id"]},"module":"recipes","safety":"write"},{"name":"update_recipe","description":"PUT /api/recipe/:id. Why: Edit a recipe: name, yield, unit, ingredient lines, product links (phantom), brand tags and branches of its prepared material; the cost snapshot and the prepared material (name, unit, branch avg cost) are recomputed. NOT a patch — omitted lines, links, brands and header fields are wiped — so always start from get_recipe. The mode cannot be changed. No stock moves. Full replace, not a patch: resend every field from get_recipe (lines, products, brand_ids, location_id, unit_id, current mode). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_recipe\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/recipe/:id","why":"Edit a recipe: name, yield, unit, ingredient lines, product links (phantom), brand tags and branches of its prepared material; the cost snapshot and the prepared material (name, unit, branch avg cost) are recomputed. NOT a patch — omitted lines, links, brands and header fields are wiped — so always start from get_recipe. The mode cannot be changed. No stock moves.","response":{"description":"200 → { message: \"Recipe Updated Successfully\", data: { recipe }, materialId } — note the extra `recipe` level (create has none). 400 → { message } when the message mentions a location (\"At least one location is required when updating recipe locations\", \"Selected locations are not allowed for this user\") or \"…linked to products\" (new product on a stockable recipe). 500 → { message: <error text> } (e.g. \"Column 'yield_qty' cannot be null\"); everything is rolled back.","fields":{"message":"string. \"Recipe Updated Successfully\" or the error text.","data":"object. Wrapper.","data.recipe":"object. The saved recipe (same shape as get_recipe data[0] without location_id). Empty-ish when the id did not exist.","data.recipe.id":"int. Recipe id. Pass as `id` to get_recipe / update_recipe / delete_recipe and as recipes[].recipe_id to create_work_order.","data.recipe.customer_id":"int. Tenant id. Always your own tenant; not needed.","data.recipe.name":"string. Recipe name. The recipe's prepared material (list_materials, linked_recipe = this id) carries the same name.","data.recipe.yield_qty":"number. Output of ONE batch of the recipe, in `unit` (e.g. 0.8 = the lines make 0.8 Kilogram). Recipe cost per unit = total_cost / yield_qty; a work order line qty is expressed in this unit.","data.recipe.unit":"string. Output unit label (dashboard: the UOM name, e.g. \"Kilogram\", \"Pieces\"). Also the unit of the prepared material and of product links (portion_qty).","data.recipe.version":"int. Always 1 (not incremented on update). Not needed.","data.recipe.created_by":"int. User id that created the recipe. Informational.","data.recipe.mode":"\"phantom\" | \"stockable\" | null. phantom = no stock of its own: selling a linked product (sync_invoice) deducts the ingredient materials. stockable = produced in advance by a work order into its prepared material's stock; sales/sub-recipe use deduct that prepared material. Only phantom recipes can get NEW product links. null = legacy row (treated as phantom by sales). Cannot be changed after creation.","data.recipe.sku_no":"string | null. Auto SKU \"rcp-<customer_id>-<id>\" (master tenants).","data.recipe.unit_id":"int | null. UOM id of `unit` (the dashboard's unit picker; no MCP units tool — copy it from another recipe or a material with the same unit, or send null).","data.recipe.recipe_items":"array. Ingredient lines — only lines whose material is still active in your tenant (lines of deactivated/deleted materials are hidden and are DROPPED if you save this list back).","data.recipe.recipe_items[].recipe_id":"int. This recipe id.","data.recipe.recipe_items[].material_id":"int. Ingredient material id (get_material / list_materials). A prepared material of another recipe (material_type \"rm\") makes this a sub-recipe line.","data.recipe.recipe_items[].qty":"number. Quantity of the ingredient in ONE batch, in the material's ingredient unit (`ingredientunit`), BEFORE yield loss. Stock actually consumed = qty / (effective_yield / 100).","data.recipe.recipe_items[].unit":"string. Unit label saved with the line (the dashboard saves the material's ingredientunit). Informational — costing and stock maths assume the ingredient unit.","data.recipe.recipe_items[].wastage_pct":"number. Legacy column, always 0; not used by costing (use yield_pct). Not needed.","data.recipe.recipe_items[].id":"int. recipe_items row id. Lines are deleted and re-inserted on every update, so ids change. Not needed.","data.recipe.recipe_items[].type":"\"mat\" | \"sr\". Set by the server from the material: \"sr\" = the material is another recipe's prepared material (sub-recipe), \"mat\" = raw material.","data.recipe.recipe_items[].unit_id":"int | null. UOM id stored on the line (null when not sent; the dashboard does not send it).","data.recipe.recipe_items[].yield_pct":"number | null. Line yield override in % (usable portion after prep; 80 = 20 % loss). null = inherit the material's yield_percentage (100 when unset).","data.recipe.recipe_items[].name":"string. Material name.","data.recipe.recipe_items[].ingredientunit":"string | null. The material's ingredient (recipe) unit, e.g. \"Gram\". `qty` is in this unit.","data.recipe.recipe_items[].conversionrate":"string. Ingredient units per storage unit (e.g. \"1000\" g per kg); comes back as a string. Cost per ingredient unit = avg_price / conversionrate.","data.recipe.recipe_items[].avg_price":"number. Material average cost per STORAGE unit (tenant-level materials.avg_price).","data.recipe.recipe_items[].price":"number. Material list price per storage unit (0 when unset). Informational.","data.recipe.recipe_items[].material_yield":"number | null. The material's default yield_percentage. In current code; NOT returned by dev1's build yet (compute from get_material).","data.recipe.recipe_items[].effective_yield":"number. yield_pct ?? material yield_percentage ?? 100 — the yield actually used for cost and stock. In current code; NOT returned by dev1's build yet (compute it as described).","data.recipe.product_ids":"int[]. Ids of products linked to this recipe (product_recipes).","data.recipe.linked_products":"array. The same links with qty_used. Resend as update_recipe `products` ([{ id, qty_used }]) or they are removed.","data.recipe.linked_products[].product_id":"int. Linked product id (get_product).","data.recipe.linked_products[].id":"int. Same as product_id.","data.recipe.linked_products[].name":"string. Product name (\"Product #<id>\" when the product row is gone).","data.recipe.linked_products[].qty_used":"number. How many whole recipes one sold unit of the product consumes (default 1). A sale deducts qty_used × every line's yield-adjusted qty (the recipe's yield_qty is not a divisor).","data.recipe.linked_products[].portion_qty":"number | null. Copy of the recipe's yield_qty at the time the link was saved. Informational.","data.recipe.linked_products[].unit":"string | null. Copy of the recipe's unit at link time. Informational.","data.recipe.brand_ids":"int[]. Brand ids (list_brands) the recipe is tagged with ([] = none). Resend on update_recipe or they are cleared.","materialId":"int | null. Id of the recipe's prepared material (list_materials / get_material)."},"master_rules":["Dashboard edit form = the create form pre-filled from get_recipe; the mode selector is disabled (\"Recipe mode cannot be changed after creation to maintain inventory integrity\"). It always sends the complete body: name, yield_qty, unit, unit_id, mode, every recipe_items line, products + product_ids, brand_ids and location_id. Do the same: get_recipe → change only what the user asked → send everything.","Omitted keys are NOT kept: recipe_items → all lines deleted; products + product_ids → all product links removed (products then stop deducting this recipe on sale); brand_ids → tags cleared; unit_id → null; mode → the prepared material's mode set NULL; name / yield_qty / unit → 500.","Stockable recipes: the dashboard keeps existing product links but offers no new ones (\"Stockable recipes cannot be linked to products. Existing links can be kept or removed.\"); a new product id → 400.","Branches: location_id is the set of branches where the prepared material exists. Removing a branch deletes its materials_locations row including any stock of the prepared item there — check get_material_stock / list_materials for the item before removing a branch.","Line maths (same as the dashboard and the server): effective_yield = yield_pct ?? material yield_percentage ?? 100; cost per ingredient unit = material avg_price / conversionrate; line cost = qty × that / (effective_yield / 100) (4 decimals); batch cost = Σ line costs; unit_cost = batch cost / yield_qty. The dashboard shows the new costs for confirmation before saving; show the user the new batch/unit cost first.","Changing lines or yield changes future sales deductions (phantom) and the consumption lines of NEW work orders; existing work orders keep the material lines they were created with (update_work_order recomputes only what you send). Products' product_cost (get_product) is not refreshed by this call.","Confirm with get_recipe after saving."],"example_request":{"name":"Test","yield_qty":0.8,"unit":"Kilogram","unit_id":2,"mode":"stockable","recipe_items":[{"material_id":119196,"qty":450,"unit":"Gram","yield_pct":null},{"material_id":119158,"qty":500,"unit":"Gram","yield_pct":null}],"products":[{"id":257508,"qty_used":1}],"product_ids":[257508],"brand_ids":[412,473],"location_id":[907,909,908,922,910,1101,1102,1100,1117]},"example":{"message":"Recipe Updated Successfully","data":{"recipe":{"id":16779,"customer_id":437,"name":"Test","yield_qty":0.8,"unit":"Kilogram","version":1,"created_by":4734,"mode":"stockable","sku_no":"rcp-437-16779","unit_id":2,"recipe_items":[{"recipe_id":16779,"material_id":119196,"qty":450,"unit":"Gram","wastage_pct":0,"id":988201,"type":"mat","unit_id":1,"yield_pct":null,"name":"Test Material","ingredientunit":"Gram","conversionrate":"1000","avg_price":22.0271,"price":0},{"recipe_id":16779,"material_id":119158,"qty":500,"unit":"Gram","wastage_pct":0,"id":988202,"type":"mat","unit_id":1,"yield_pct":null,"name":"Cherry Blosom","ingredientunit":"Gram","conversionrate":"1000","avg_price":0.3295,"price":0}],"product_ids":[257508],"linked_products":[{"product_id":257508,"id":257508,"name":"Mocha","qty_used":1,"portion_qty":0.8,"unit":"Kilogram"}],"brand_ids":[412,473]}},"materialId":119300}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Recipe id (list_recipes / get_recipe)."},"name":{"type":"string","description":"Recipe name (NOT NULL; omitted → 500, rolled back)."},"yield_qty":{"type":"number","description":"Output of one batch in `unit` (> 0)."},"unit":{"type":"string","description":"Output unit label; the dashboard sends the UOM name (e.g."},"unit_id":{"type":"number","description":"UOM id matching `unit` (no MCP units tool: copy from another recipe/material with the same unit, else null)."},"mode":{"type":"string","enum":["phantom","stockable"],"description":"Resend the recipe's CURRENT mode (get_recipe)."},"recipe_items":{"type":"array","items":{"type":"object","properties":{"material_id":{"type":"number","description":"Ingredient material id (list_materials / get_material)."},"qty":{"type":"number","description":"Quantity in ONE batch, in the material's ingredientunit (e.g."},"unit":{"type":"string","description":"The material's ingredientunit (get_material / list_materials)."},"yield_pct":{"type":"number","description":"Line yield override in % (0.01+; 80 = 20 % prep loss, >100 = expansion)."},"unit_id":{"type":"number","description":"UOM id stored on the line."}},"required":["material_id","qty","unit"],"description":""},"description":"FULL list of ingredient lines — replaces all lines."},"products":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number","description":"Product id (list_products / get_product)."},"qty_used":{"type":"number","description":"Whole recipes consumed per product unit sold (> 0; default 1)."}},"required":["id"],"description":""},"description":"Product links for a PHANTOM recipe: [{ id, qty_used }]."},"product_ids":{"type":"array","items":{"type":"number","description":""},"description":"Product ids to link (each gets qty_used 1)."},"brand_ids":{"type":"array","items":{"type":"number","description":""},"description":"Brand ids (list_brands)."},"location_id":{"type":"array","items":{"type":"number","description":""},"description":"Branches (list_locations) of the prepared material, as an array (the server also accepts a number or csv)."}},"required":["id","name","yield_qty","unit","mode","recipe_items"]},"module":"recipes","safety":"write"},{"name":"delete_recipe","description":"DELETE /api/recipe/:id. Why: Permanently delete a recipe: its lines, cost snapshot and product links, plus its prepared material and that material's rows at every branch (including any stock on hand). No undo. Linked products stop deducting ingredients on sale; other recipes that use it as a sub-recipe keep a dangling line. Check get_recipe (linked_products) and get_material_used_in_recipes on its prepared material first. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_recipe\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/recipe/:id","why":"Permanently delete a recipe: its lines, cost snapshot and product links, plus its prepared material and that material's rows at every branch (including any stock on hand). No undo. Linked products stop deducting ingredients on sale; other recipes that use it as a sub-recipe keep a dangling line. Check get_recipe (linked_products) and get_material_used_in_recipes on its prepared material first.","response":{"description":"200 → { success: true, message: \"Recipe deleted successfully\" }. 404 → { success: false, message: \"Recipe not found\" } (unknown id or another tenant's). 500 → { success: false, message: \"Failed to delete recipe\", error }. Not transactional. Deleted: recipe_items, recipe_cost (+ material cost rows), product_recipes, the recipe, and the material whose NAME equals the recipe name with all its materials_locations rows. Not deleted: brand tags, modifier-item recipe links, work-order history lines, other recipes' lines that use the prepared material.","fields":{"success":"boolean. true on delete; false on 404/500.","message":"string. \"Recipe deleted successfully\" | \"Recipe not found\" | \"Failed to delete recipe\".","error":"string. DB error text (500 only)."},"example":{"success":true,"message":"Recipe deleted successfully"},"master_rules":["Dashboard: row Delete in the recipe list (and bulk \"Delete Selected\", another route with no MCP tool), each behind the confirm \"Are you sure? You won't be able to revert this!\". Ask the user to confirm the same way, naming what else goes: product links, the prepared material and its stock.","Before deleting: get_recipe → linked_products (those products lose their recipe — relink another phantom recipe via update_product); list_materials k=<recipe name> → the prepared material id → get_material_used_in_recipes (recipes that use it as an ingredient would keep a line to a deleted material — edit them first with update_recipe) and get_material_stock (stock on hand is discarded without a movement).","The prepared material is found by NAME: if a raw material has the same name as the recipe, that material is deleted instead of/in addition to the real one. If names differ (renamed outside the recipe), the prepared material is left behind.","Do not delete a recipe used by open (draft / in_progress) work orders (list_work_orders recipe_id=<id>): completing them would find no prepared material and skip the production.","Verify with get_recipe (→ 404) and list_recipes."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Recipe id (list_recipes / get_recipe)."}},"required":["id"]},"module":"recipes","safety":"destructive"},{"name":"create_inventory_count_sheet","description":"POST /api/inventory-count-sheets/create-sheet. Why: Save a reusable count-sheet template: a named list of materials (list_materials ids) assigned to branches (list_locations ids). A sheet only pre-selects materials for a later sheet-based count (create_inventory_count type 2); creating it does not create a count or touch stock. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_inventory_count_sheet\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/inventory-count-sheets/create-sheet","why":"Save a reusable count-sheet template: a named list of materials (list_materials ids) assigned to branches (list_locations ids). A sheet only pre-selects materials for a later sheet-based count (create_inventory_count type 2); creating it does not create a count or touch stock.","response":{"description":"200 → { status: true, message: \"Inventory count sheet created successfully\" } — no id or record is returned; call list_inventory_count_sheets (filtered by the sheet's branch) to get it. 400 → { status: false, message: \"Inventory count sheet with this name already exists\" }. 500 → { status: false, message: \"Database error\" }. Not transactional: header, materials and branches are written by separate queries, so a failure part-way leaves a partial sheet.","fields":{"status":"boolean. true on success; false on every error.","message":"string. \"Inventory count sheet created successfully\" on success; on error \"Inventory count sheet with this name already exists\" (400, create only) or \"Database error\" (500)."},"master_rules":["Build the body like the dashboard drawer: { name, reference, location_id: [<branch id>, …], material_ids: [<material id>, …] } — location_id is an ARRAY of numbers, material_ids an array of material ids. The dashboard requires name, reference, ≥ 1 branch and ≥ 1 material; do the same.","Pick materials the way the dashboard does: list_materials for the chosen branch(es) (stockable materials only), use data[].id. Check the name is unused first (duplicates → 400).","No id comes back. To edit or delete the new sheet you need its id from list_inventory_count_sheets, which is currently broken (API crash) — tell the user the sheet was created and can be managed in the dashboard (Inventory → Count Sheets).","A sheet is only a template. The dashboard's next step is Inventory Count → Count Sheet tab → pick the sheet → enter quantities → save (create_inventory_count with type 2, is_partial 0 and one materials row per sheet material with expected_quantity from the branch stock)."],"example_request":{"name":"Weekly bar count","reference":"BAR-W","location_id":[907],"material_ids":[119196,119197]},"example":{"status":true,"message":"Inventory count sheet created successfully"}},"parameters":{"type":"object","properties":{"name":{"type":"string","description":"Sheet name."},"reference":{"type":"string","description":"Free-text reference / sheet code."},"material_ids":{"type":"array","items":{"type":"number","description":"Material id (list_materials → data[].id; stockable materials)."},"description":"Every material on the sheet (the dashboard requires ≥ 1 and picks from stockable materials of the selected branches)."},"location_id":{"type":"array","items":{"type":"number","description":"Branch id (list_locations)."},"description":"Branches the sheet is assigned to — ALWAYS an array of numbers (the dashboard sends e.g."}},"required":["name","material_ids","location_id"]},"module":"inventory_counts","safety":"write"},{"name":"update_inventory_count_sheet","description":"PUT /api/inventory-count-sheets/update-sheet/:id. Why: Rename a count-sheet template or change its materials / branches. NOT a patch: name, reference and material_ids are overwritten and branches are synced to exactly location_id — resend the full sheet with your change. Template only; no count or stock change. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_inventory_count_sheet\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/inventory-count-sheets/update-sheet/:id","why":"Rename a count-sheet template or change its materials / branches. NOT a patch: name, reference and material_ids are overwritten and branches are synced to exactly location_id — resend the full sheet with your change. Template only; no count or stock change.","response":{"description":"200 → { status: true, message: \"Inventory count sheet updated successfully\" } — no id or record is returned; call list_inventory_count_sheets (filtered by the sheet's branch) to get it. 500 → { status: false, message: \"Database error\" } (also when the id does not exist or is deleted — the UPDATE matches 0 rows). Not transactional: header, materials and branches are written by separate queries, so a failure part-way leaves a partial sheet.","fields":{"status":"boolean. true on success; false on every error.","message":"string. \"Inventory count sheet updated successfully\" on success; on error \"Inventory count sheet with this name already exists\" (400) or \"Database error\" (500)."},"master_rules":["The dashboard edit drawer loads the sheet, lets the user change name / reference / branches / materials and sends { name, reference, location_id: number[], material_ids: number[] } — always the complete lists. Do the same: start from the current sheet, apply the change, resend everything.","Omitting a field is destructive: missing name/reference → NULL, missing material_ids → sheet emptied, missing location_id (or a non-array) → every branch assignment removed (the sheet then disappears from branch-filtered lists).","No duplicate-name check on update (unlike create) — make sure the new name is not used by another sheet.","created_by is overwritten with the current user on every update."],"example_request":{"id":12,"name":"Weekly bar count","reference":"BAR-W","location_id":[907],"material_ids":[119196,119197,119198]},"example":{"status":true,"message":"Inventory count sheet updated successfully"}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Count sheet id (list_inventory_count_sheets → data[].id)."},"name":{"type":"string","description":"Sheet name."},"reference":{"type":"string","description":"Free-text reference / sheet code."},"material_ids":{"type":"array","items":{"type":"number","description":"Material id (list_materials → data[].id; stockable materials)."},"description":"Every material on the sheet (the dashboard requires ≥ 1 and picks from stockable materials of the selected branches)."},"location_id":{"type":"array","items":{"type":"number","description":"Branch id (list_locations)."},"description":"Branches the sheet is assigned to — ALWAYS an array of numbers (the dashboard sends e.g."}},"required":["id","name","material_ids","location_id"]},"module":"inventory_counts","safety":"write"},{"name":"delete_inventory_count_sheet","description":"DELETE /api/inventory-count-sheets/delete-sheet/:id. Why: Remove a count-sheet template (soft delete: is_deleted = 1). Existing counts made from it are untouched (counts do not store the sheet id) and no stock changes. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_inventory_count_sheet\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/inventory-count-sheets/delete-sheet/:id","why":"Remove a count-sheet template (soft delete: is_deleted = 1). Existing counts made from it are untouched (counts do not store the sheet id) and no stock changes.","response":{"description":"200 → { status: true, message: \"Inventory count sheet deleted successfully\" }. 404 → { status: false, message: \"Inventory count sheet not found or already deleted\" }. 500 → { status: false, message: \"Database error\" }. Soft delete: sets is_deleted = 1, deleted_by = current user, deleted_at = NOW(); material and branch links are kept but the sheet no longer appears anywhere. There is no restore tool.","fields":{"status":"boolean. true when a sheet was deleted; false on 404/500.","message":"string. \"Inventory count sheet deleted successfully\" | \"Inventory count sheet not found or already deleted\" | \"Database error\"."},"master_rules":["The dashboard deletes from the Count Sheets list (trash icon) after a confirm dialog, then refreshes the list. Confirm with the user by sheet name before calling.","Deleting a sheet frees its name for a new sheet (the uniqueness check ignores deleted sheets). Counts already created from the sheet stay as they are."],"example_request":{"id":12},"example":{"status":true,"message":"Inventory count sheet deleted successfully"}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Count sheet id (list_inventory_count_sheets → data[].id)."}},"required":["id"]},"module":"inventory_counts","safety":"destructive"},{"name":"get_purchase_order","description":"GET /api/supplier-invoices/:uuid. Why: Full purchase order or draft: header (supplier snapshot, dates, refs, status, totals, who received/approved), every line with material, unit, qty, requested qty, unit price and line total, plus the branch's business info. Load it before update_draft_purchase_order_lines (you must resend all lines and keep the VAT rate) or delete_draft_purchase_order (check is_draft = 1), and to answer \"what's on PO-123\". uuid from list_supplier_invoices. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_purchase_order\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/supplier-invoices/:uuid","why":"Full purchase order or draft: header (supplier snapshot, dates, refs, status, totals, who received/approved), every line with material, unit, qty, requested qty, unit price and line total, plus the branch's business info. Load it before update_draft_purchase_order_lines (you must resend all lines and keep the VAT rate) or delete_draft_purchase_order (check is_draft = 1), and to answer \"what's on PO-123\". uuid from list_supplier_invoices.","response":{"description":"200 → array with one purchase object: header columns (see fields), items[] (purchase_materials rows + material unit/ingredientunit + brand_name) and business_info[]. 404 → { error: \"Purchase invoice not found\" }. 500 → { error: \"Database error\" }.","fields":{"[]":"array. Exactly one purchase header object.","[].id":"int. PO NUMBER (invoice_id, else row id) — same as oid on list_supplier_invoices, shown as \"PO-<id>\". Not the uuid.","[].items_number":"int. Number of lines.","[].subtotal":"number. Sum of line totals (qty × unit price) before VAT, as stored by the dashboard.","[].vat":"number. VAT AMOUNT for the whole PO (2 decimals), not a percent. VAT % = vat / subtotal × 100.","[].payable":"number. Grand total = subtotal + vat (the dashboard does not subtract discountamount).","[].discountamount":"number | null. Header discount amount (normally 0). Not applied to payable by the dashboard.","[].discountpct":"number | null. Legacy discount % (normally 0/null). Not needed.","[].paid":"number | null. Legacy paid amount (0/null). Not needed.","[].change":"int. SUPPLIER RESPONSE status (not money): 0 = Processing (not answered), 1 = Sent, 2 = Accepted, 3 = Declined — the dashboard \"Supplier Status\" pill.","[].note":"string | null. Header note (orders from stock order requests say \"From order request N\").","[].published_date":"string (ISO) | null. Purchase date the user entered (date only; null on drafts).","[].uuid":"string. PO uuid — the key for get_purchase_order and every PO/draft write tool.","[].created_at":"string (ISO). Creation DATE only (time is 00:00, tenant time zone).","[].customer_id":"int. Tenant id. Not needed.","[].location_id":"int. Branch the PO delivers to (list_locations).","[].info_name":"string | null. Supplier name snapshot at creation (dashboard \"Supplier\" column).","[].info_address":"string | null. Supplier address snapshot.","[].info_phone":"string | null. Supplier phone snapshot.","[].info_email":"string | null. Supplier email snapshot.","[].status":"string. PO status: draft | awaiting (draft marked Receive Later) | pending_approval (legacy) | pending (confirmed, sent to supplier, receivable) | partially_received (some drafts open after a short receipt) | delivered (received — stock posted; dashboard label \"Completed\") | canceled / cancelled | new / completed (legacy).","[].user":"string | null. Username of the creator.","[].table_id":"int | null. Supplier id (list_suppliers / get_supplier).","[].your_order_ref":"string | null. Your reference (e.g. \"REQ-12\" for POs from order request 12). Search with k.","[].supplier_order_ref":"string | null. Supplier's reference.","[].is_draft":"0 | 1. 1 = draft (editable/deletable by MCP; not sent to the supplier; no stock).","[].parent_purchase_uuid":"string | null. For receive-shortfall drafts: uuid of the PO it was split from. null for normal POs and for drafts generated from stock order requests.","[].is_archive":"0 | 1. 1 = archived (read-only in the dashboard; hidden unless is_archive=1/all).","[].delivery_date":"string (ISO) | null. Expected delivery date.","[].delivery_details":"string | null. Multi-line delivery address block printed on the PO (name, address, Email:, VAT:, Phone:). null on older rows.","[].supplier_invoice_number":"string | null. Supplier's invoice number entered at receiving (required to receive).","[].supplier_invoice_id":"int | null. System running number of POs per supplier (assigned at creation/confirmation).","[].received_by":"int | null. User id who received the PO (stock-in).","[].received_at":"string (ISO) | null. When it was received.","[].received_by_name":"string | null. Username of received_by.","[].approved_by":"int | null. Legacy manager approval: user id.","[].approved_at":"string (ISO) | null. Legacy approval time.","[].approved_by_name":"string | null. Username of approved_by.","[].rejected_by":"int | null. User id who rejected/cancelled it.","[].rejected_at":"string (ISO) | null. Rejection time.","[].rejected_by_name":"string | null. Username of rejected_by.","[].order_id":"int | null. Running order counter per branch at creation. Informational.","[].parent_order_id":"int | null. PO number of the parent PO for a shortfall draft; null otherwise (confirming a draft clears its parent link).","[].location_name":"string | null. Branch name.","[].items":"array. Lines of this PO/draft ([] = none).","[].items[].id":"int. Line row id. Informational (line writes key on material_id).","[].items[].name":"string. Material name snapshot on the line.","[].items[].price":"number. Unit price (4 decimals) per `unit`.","[].items[].qty":"number. Quantity (4 decimals) in `unit`. Drafts / pending POs: ordered qty; after receiving: the received qty.","[].items[].subtotal":"number. Line total cost as stored (normally qty × price). Dashboard \"Total cost\".","[].items[].purchase_uuid":"string. uuid of the PO/draft this line belongs to.","[].items[].customer_id":"int. Tenant id.","[].items[].location_id":"int. Branch (same as the header).","[].items[].material_id":"int. Material id (get_material / list_materials).","[].items[].extras":"null. Legacy. Not needed.","[].items[].custom_price":"number | null. Unit price actually used (= price on current rows).","[].items[].product_id":"null. Legacy. Not needed.","[].items[].unit":"string | null. Material unit (from materials.unit) — the unit of qty and price. No conversion is applied.","[].items[].wastage":"null. Legacy. Not needed.","[].items[].brand_id":"int | null. Brand of the line (brand_name).","[].items[].requested_qty":"number | null. Quantity originally ordered; qty < requested_qty after receiving = shortfall.","[].items[].remark":"string | null. Line note.","[].items[].ingredientunit":"string | null. Material recipe/ingredient unit — informational; PO qty is in `unit`.","[].items[].brand_name":"string | null. Brand name for brand_id.","[].business_info":"array. Branch business-info config rows (buyer block printed on the PO); falls back to the tenant defaults.","[].business_info[].id":"int. Config row id. Not needed.","[].business_info[].name":"string. Config key: info_name | info_address | info_email | info_phone | info_vat_no | logo_image.","[].business_info[].value":"string. Its value.","error":"string. 404 \"Purchase invoice not found\"; 500 \"Database error\"."},"example":[{"id":6187,"items_number":1,"subtotal":12,"vat":0.6,"payable":12.6,"discountamount":0,"discountpct":0,"paid":0,"change":0,"note":"","published_date":null,"delivery_date":null,"delivery_details":null,"uuid":"52e14591-f11f-430c-8672-aa9412cc3eb9","created_at":"2026-05-19T00:00:00.000Z","customer_id":437,"location_id":907,"info_name":"Faisal Suppliers","info_address":"Industrial Area 2, Sharjah","info_phone":"+971500000003","info_email":"sales@faisal.example","status":"draft","user":"Nexus Cafe","table_id":881,"your_order_ref":"","supplier_order_ref":"","supplier_invoice_number":null,"supplier_invoice_id":3,"received_by":null,"received_at":null,"received_by_name":null,"approved_by":null,"approved_at":null,"approved_by_name":null,"rejected_by":null,"rejected_at":null,"rejected_by_name":null,"is_draft":1,"parent_purchase_uuid":"6f6db90f-2a7d-4910-82e9-3a0dae7e6e70","order_id":18,"is_archive":0,"parent_order_id":6180,"location_name":"Default location","items":[{"id":14292,"name":"\t SRC-BT-86CBPR-SG3 BB86-BB92 INTERLOCK SHIMANO CERAMIC COATED BLACK","price":2,"qty":6,"subtotal":12,"purchase_uuid":"52e14591-f11f-430c-8672-aa9412cc3eb9","customer_id":437,"location_id":907,"material_id":112157,"extras":null,"custom_price":2,"product_id":null,"unit":"Pieces","wastage":null,"brand_id":null,"requested_qty":6,"remark":null,"ingredientunit":"Pieces","brand_name":null}],"business_info":[{"id":9993,"value":"Nexus","name":"info_name"},{"id":9994,"value":"Nexus located at Dev","name":"info_address"},{"id":9995,"value":"info@buyer.example","name":"info_email"},{"id":10237,"value":"+971500000009","name":"info_phone"},{"id":9996,"value":"100000000000003","name":"info_vat_no"},{"id":9997,"value":"1762167059218-logo.png","name":"logo_image"}]}],"master_rules":["Read element [0]. Key everything on uuid; `id` here is the PO number (\"PO-<id>\").","Before any draft write check is_draft = 1 and is_archive = 0. Non-drafts (pending, partially_received, delivered…) are managed only in the dashboard (Accept/Decline, Receive, Cancel, Archive, Returns).","Line maths as the dashboard: line total = qty × unit price (4 decimals); subtotal = Σ line totals; VAT % = vat / subtotal × 100 of the stored header (0 when vat = 0 — VAT switched off); vat = subtotal × VAT% / 100; payable = subtotal + vat. The view shows the stored numbers without recalculating.","Quantities and prices are per the material's `unit`; there is no unit conversion on POs (ingredientunit is informational).","Shortfall: after receiving, requested_qty − qty per line is what the supplier did not deliver; the dashboard can turn it into draft POs (visible in list_supplier_invoices with parent_purchase_uuid = this uuid).","Stock-in happens only when the dashboard receives the PO (status delivered, received_by/received_at set); confirm with list_inventory_movements."]},"parameters":{"type":"object","properties":{"uuid":{"type":"string","description":"PO/draft uuid (list_supplier_invoices → uuid; not id/oid)."}},"required":["uuid"]},"module":"purchase_orders","safety":"read"},{"name":"create_draft_purchase_order","description":"POST /api/supplier-invoices/draft. Why: Create a DRAFT purchase order (is_draft = 1, status draft) for a supplier, split from an existing PO — the dashboard does this when a delivery is short: the missing quantities become drafts (possibly for another supplier) that a user later confirms in the dashboard. Nothing is sent to the supplier and no stock moves. Needs the parent PO uuid (get_purchase_order / list_supplier_invoices), the supplier (list_suppliers), material ids and prices (list_supplier_materials, list_materials). The draft only becomes a real PO when a user presses Confirm in the dashboard; stock arrives only when that PO is received there (list_inventory / list_inventory_movements). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_draft_purchase_order\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/supplier-invoices/draft","why":"Create a DRAFT purchase order (is_draft = 1, status draft) for a supplier, split from an existing PO — the dashboard does this when a delivery is short: the missing quantities become drafts (possibly for another supplier) that a user later confirms in the dashboard. Nothing is sent to the supplier and no stock moves. Needs the parent PO uuid (get_purchase_order / list_supplier_invoices), the supplier (list_suppliers), material ids and prices (list_supplier_materials, list_materials). The draft only becomes a real PO when a user presses Confirm in the dashboard; stock arrives only when that PO is received there (list_inventory / list_inventory_movements).","response":{"description":"201 → { success: true, draft: { …purchase row (all columns), items: [lines with material_name, unit_id, unit_name] } }. 400/404/500 → { error } (see fields). Transactional: on error nothing is saved. The parent PO's status is NOT recomputed by this call (the dashboard does that through a route MCP may not call).","fields":{"success":"boolean. true on 201.","draft":"object. The created purchase row (SELECT *) plus items.","draft.id":"int. Row id (becomes the PO number when invoice_id is null).","draft.items_number":"int. Number of lines.","draft.subtotal":"number. As sent.","draft.vat":"number. VAT amount as sent (2 dp).","draft.payable":"number. As sent.","draft.discountamount":"number. As sent (default 0).","draft.discountpct":"number. 0.","draft.paid":"number. 0.","draft.change":"int. 0 (supplier status Processing).","draft.note":"string. As sent or \"\".","draft.uuid":"string. NEW draft uuid — use it for update_draft_purchase_order_lines / delete_draft_purchase_order / get_purchase_order.","draft.created_at":"string (ISO). Insert time (UTC).","draft.customer_id":"int. Tenant.","draft.location_id":"int. Branch used.","draft.info_name":"string. Supplier snapshot.","draft.info_address":"string. Supplier snapshot.","draft.info_phone":"string. Supplier snapshot.","draft.info_email":"string. Supplier snapshot.","draft.status":"string. \"draft\".","draft.user":"string. Caller username.","draft.user_id":"int. Caller user id.","draft.table_id":"int | null. Supplier id as sent.","draft.payment_method":"null.","draft.order_id":"int. Running counter per branch.","draft.invoice_id":"int | null. Legacy PO number column (null).","draft.custom_price":"null. Legacy.","draft.your_order_ref":"string. As sent or \"\".","draft.supplier_order_ref":"string. As sent or \"\".","draft.published_date":"null. Purchase date is set on confirm in the dashboard.","draft.is_draft":"int. 1.","draft.parent_purchase_uuid":"string. As sent.","draft.delivery_date":"null.","draft.order_request_uuid":"null (only for drafts generated from order requests).","draft.supplier_invoice_number":"null.","draft.received_by":"null.","draft.received_at":"null.","draft.approved_by":"null.","draft.approved_at":"null.","draft.rejected_by":"null.","draft.rejected_at":"null.","draft.supplier_invoice_id":"null (assigned on confirm).","draft.delivery_details":"string. As sent or the branch snapshot.","draft.is_archive":"int. 0.","draft.items":"array. Created lines.","draft.items[].id":"int. Line row id.","draft.items[].name":"string | null. As sent.","draft.items[].price":"number. Unit price used (custom_price ?? price).","draft.items[].qty":"number. Quantity.","draft.items[].subtotal":"number. Line total as sent.","draft.items[].purchase_uuid":"string. The draft uuid.","draft.items[].customer_id":"int. Tenant.","draft.items[].location_id":"int. Branch.","draft.items[].material_id":"int. Material id.","draft.items[].extras":"null.","draft.items[].custom_price":"number. = price.","draft.items[].product_id":"null.","draft.items[].unit":"null (not stored on the line; see unit_name).","draft.items[].wastage":"null.","draft.items[].brand_id":"int | null.","draft.items[].requested_qty":"number. = qty.","draft.items[].remark":"string | null.","draft.items[].material_name":"string | null. Current material name.","draft.items[].unit_id":"int | null. Material UOM id.","draft.items[].unit_name":"string | null. UOM name (else materials.unit) — the unit of qty.","error":"string. Errors only: 400 \"parent_purchase_uuid is required\" | \"items array is required\" | \"subtotal, vat, and payable are required and must be valid numbers.\" | \"Each line item must include a valid subtotal.\" | archived-PO message; 404 \"Parent purchase order not found\"; 500 \"Failed to create draft purchase order\".","item":"object. With the line-subtotal 400 only: { material_id } of the bad line.","item.material_id":"int. Material id of the offending line."},"example_request":{"parent_purchase_uuid":"6f6db90f-2a7d-4910-82e9-3a0dae7e6e70","location_id":907,"table_id":881,"info_name":"Faisal Suppliers","info_phone":"+971500000003","info_email":"sales@faisal.example","info_address":"Industrial Area 2, Sharjah","your_order_ref":"","items":[{"material_id":112157,"name":"SRC-BT-86CBPR-SG3 BB86-BB92","qty":6,"price":2,"custom_price":2,"subtotal":12}],"subtotal":12,"vat":0.6,"payable":12.6},"example":{"success":true,"draft":{"id":6187,"items_number":1,"subtotal":12,"vat":0.6,"payable":12.6,"discountamount":0,"discountpct":0,"paid":0,"change":0,"note":"","uuid":"52e14591-f11f-430c-8672-aa9412cc3eb9","created_at":"2026-05-19T16:16:14.000Z","customer_id":437,"location_id":907,"info_name":"Faisal Suppliers","info_address":"Industrial Area 2, Sharjah","info_phone":"+971500000003","info_email":"sales@faisal.example","status":"draft","user":"Nexus Cafe","user_id":1201,"table_id":881,"payment_method":null,"order_id":18,"invoice_id":null,"custom_price":null,"your_order_ref":"","supplier_order_ref":"","published_date":null,"is_draft":1,"parent_purchase_uuid":"6f6db90f-2a7d-4910-82e9-3a0dae7e6e70","delivery_date":null,"order_request_uuid":null,"supplier_invoice_number":null,"received_by":null,"received_at":null,"approved_by":null,"approved_at":null,"rejected_by":null,"rejected_at":null,"supplier_invoice_id":null,"delivery_details":"Nexus\nNexus located at Dev\nEmail: info@buyer.example\nVAT: 100000000000003\nPhone: +971500000009","is_archive":0,"items":[{"id":14292,"name":"SRC-BT-86CBPR-SG3 BB86-BB92","price":2,"qty":6,"subtotal":12,"purchase_uuid":"52e14591-f11f-430c-8672-aa9412cc3eb9","customer_id":437,"location_id":907,"material_id":112157,"extras":null,"custom_price":2,"product_id":null,"unit":null,"wastage":null,"brand_id":null,"requested_qty":6,"remark":null,"material_name":"SRC-BT-86CBPR-SG3 BB86-BB92","unit_id":null,"unit_name":"Pieces"}]}},"master_rules":["This tool cannot create a brand-new PO: a draft always hangs off an existing purchase (parent_purchase_uuid). Brand-new POs, and POs from stock order requests (list_stock_order_requests), are created only in the dashboard.","Dashboard flow it mirrors: receiving a pending PO with less than ordered opens a variance dialog; for each short line the user picks a supplier (default the PO's supplier); lines are grouped per supplier and ONE draft per supplier is created with qty = requested_qty − received qty, price = the line's unit price, custom_price = price, subtotal = round4(qty × price), plus parent_purchase_uuid, the supplier's info_name/info_phone/info_email/info_address/table_id, location_id = parent's, your_order_ref = parent's.","Totals like the dashboard: subtotal = Σ line subtotals; VAT% = parent.vat / parent.subtotal × 100 (0 if the parent has no VAT); vat = round4(subtotal × VAT% / 100) (amount, not percent); payable = subtotal + vat. The server stores what you send and does not check the maths.","Get supplier fields from get_supplier/list_suppliers (name → info_name, contact_phone → info_phone, contact_email → info_email, address → info_address, id → table_id); materials and prices from list_supplier_materials sup=<supplier>&loc=<branch> (purchase_price, else avg_price).","Quantities/prices are per the material's unit (no conversion). Validate qty > 0, price ≥ 0, at least one line.","After creating, the dashboard also recomputes the parent's status (→ partially_received) through a route MCP may not call; tell the user the draft will show under the parent PO and the parent status updates when they act on it in the dashboard.","What only the dashboard can do next: Confirm (draft → pending PO, supplier emailed), Receive Later (status awaiting), then supplier Accept/Decline and Receive (stock-in). Never promise stock from a draft; verify stock later with list_inventory / list_inventory_movements.","Read back with get_purchase_order(draft.uuid)."]},"parameters":{"type":"object","properties":{"parent_purchase_uuid":{"type":"string","description":"uuid of the existing (non-archived) purchase order this draft is split from (list_supplier_invoices / get_purchase_order)."},"location_id":{"type":"number","description":"Branch of the draft — send the parent PO's location_id (one id)."},"table_id":{"type":"number","description":"Supplier id of the draft (list_suppliers)."},"info_name":{"type":"string","description":"Supplier name snapshot = supplier.name."},"info_phone":{"type":"string","description":"Supplier phone snapshot = supplier.contact_phone."},"info_email":{"type":"string","description":"Supplier email snapshot = supplier.contact_email (where the PO email goes on confirm)."},"info_address":{"type":"string","description":"Supplier address snapshot = supplier.address."},"items":{"type":"array","items":{"type":"object","properties":{"material_id":{"type":"number","description":"Material id (list_supplier_materials → id, or list_materials)."},"name":{"type":"string","description":"Material name snapshot for the line (list_supplier_materials / get_material name)."},"qty":{"type":"number","description":"Quantity in the material's `unit` (rounded to 6 dp, stored with 4)."},"price":{"type":"number","description":"Unit price per `unit` (4 dp)."},"custom_price":{"type":"number","description":"Unit price override; wins over price."},"subtotal":{"type":"number","description":"Line TOTAL cost = round4(qty × unit price)."},"custom_subtotal":{"type":"string","description":"Alternative line total used only when subtotal is missing."},"brand_id":{"type":"number","description":"Brand id (list_supplier_materials brand_ids / list_brands)."},"remark":{"type":"string","description":"Line note (trimmed; \"\" → null)."}},"required":["material_id","qty"],"description":""},"description":"Draft lines (at least one; 400 \"items array is required\" otherwise)."},"subtotal":{"type":"number","description":"Header subtotal = Σ line subtotals (4 dp)."},"vat":{"type":"number","description":"VAT AMOUNT (not a percent) = round4(subtotal × VAT% / 100), stored with 2 decimals."},"payable":{"type":"number","description":"Grand total = subtotal + vat (4 dp)."},"discountamount":{"type":"number","description":"Header discount amount."},"note":{"type":"string","description":"Header note."},"your_order_ref":{"type":"string","description":"Your reference."},"supplier_order_ref":{"type":"string","description":"Supplier's reference."},"delivery_details":{"type":"string","description":"Multi-line delivery block printed on the PO."}},"required":["parent_purchase_uuid","items","subtotal","vat","payable"]},"module":"purchase_orders","safety":"write"},{"name":"update_draft_purchase_order_lines","description":"PUT /api/supplier-invoices/:uuid/price-quantity. Why: Replace the lines (materials, quantities, unit prices) and totals of a DRAFT purchase order — the dashboard draft editor. Load the draft with get_purchase_order first (you must resend every line you want to keep and reuse its VAT rate). Materials and prices from list_supplier_materials (sup = the draft's table_id, loc = its location_id) or list_materials. No stock moves; the supplier is not notified. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_draft_purchase_order_lines\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/supplier-invoices/:uuid/price-quantity","why":"Replace the lines (materials, quantities, unit prices) and totals of a DRAFT purchase order — the dashboard draft editor. Load the draft with get_purchase_order first (you must resend every line you want to keep and reuse its VAT rate). Materials and prices from list_supplier_materials (sup = the draft's table_id, loc = its location_id) or list_materials. No stock moves; the supplier is not notified.","response":{"description":"200 → { success: true, message: \"Your updates were saved.\", invoice: purchase row, items: all lines }. 400/404/500 → { error, item? }. Transactional: an error saves nothing. Note: the API does not check is_draft — it would also change a live PO, so check first.","fields":{"success":"boolean. true.","message":"string. \"Your updates were saved.\"","invoice":"object. The purchase row after the update (SELECT * of purchases).","invoice.id":"int. Row id (becomes the PO number when invoice_id is null).","invoice.items_number":"int. Number of lines.","invoice.subtotal":"number. Now stored.","invoice.vat":"number. VAT amount as sent (2 dp).","invoice.payable":"number. Now stored.","invoice.discountamount":"number. Now stored (default 0).","invoice.discountpct":"number. 0.","invoice.paid":"number. 0.","invoice.change":"int. 0 (supplier status Processing).","invoice.note":"string. Now stored or \"\".","invoice.uuid":"string. NEW draft uuid — use it for update_draft_purchase_order_lines / delete_draft_purchase_order / get_purchase_order.","invoice.created_at":"string (ISO). Insert time (UTC).","invoice.customer_id":"int. Tenant.","invoice.location_id":"int. Branch used.","invoice.info_name":"string. Supplier snapshot.","invoice.info_address":"string. Supplier snapshot.","invoice.info_phone":"string. Supplier snapshot.","invoice.info_email":"string. Supplier snapshot.","invoice.status":"string. \"draft\".","invoice.user":"string. Caller username.","invoice.user_id":"int. Caller user id.","invoice.table_id":"int | null. Supplier id as sent.","invoice.payment_method":"null.","invoice.order_id":"int. Running counter per branch.","invoice.invoice_id":"int | null. Legacy PO number column (null).","invoice.custom_price":"null. Legacy.","invoice.your_order_ref":"string. Now stored or \"\".","invoice.supplier_order_ref":"string. Now stored or \"\".","invoice.published_date":"null. Purchase date is set on confirm in the dashboard.","invoice.is_draft":"int. 1.","invoice.parent_purchase_uuid":"string. Now stored.","invoice.delivery_date":"null.","invoice.order_request_uuid":"null (only for drafts generated from order requests).","invoice.supplier_invoice_number":"null.","invoice.received_by":"null.","invoice.received_at":"null.","invoice.approved_by":"null.","invoice.approved_at":"null.","invoice.rejected_by":"null.","invoice.rejected_at":"null.","invoice.supplier_invoice_id":"null (assigned on confirm).","invoice.delivery_details":"string. Now stored or the branch snapshot.","invoice.is_archive":"int. 0.","items":"array. All lines of the purchase after the update (raw purchase_materials rows).","items[].id":"int. Line row id. Informational (line writes key on material_id).","items[].name":"string. Material name snapshot on the line.","items[].price":"number. Unit price (4 decimals) per `unit`.","items[].qty":"number. Quantity (4 decimals) in `unit`. Drafts / pending POs: ordered qty; after receiving: the received qty.","items[].subtotal":"number. Line total cost as stored (normally qty × price). Dashboard \"Total cost\".","items[].purchase_uuid":"string. uuid of the PO/draft this line belongs to.","items[].customer_id":"int. Tenant id.","items[].location_id":"int. Branch (same as the header).","items[].material_id":"int. Material id (get_material / list_materials).","items[].extras":"null. Legacy. Not needed.","items[].custom_price":"number | null. Unit price actually used (= price on current rows).","items[].product_id":"null. Legacy. Not needed.","items[].wastage":"null. Legacy. Not needed.","items[].brand_id":"int | null. Brand of the line (brand_name).","items[].requested_qty":"number | null. Quantity originally ordered; qty < requested_qty after receiving = shortfall.","items[].remark":"string | null. Line note.","items[].unit":"null. Not stored on lines (unit comes from the material).","error":"string. 400: missing uuid / no items / bad totals / bad qty / bad price / bad line subtotal / \"This product is not on the invoice…\" (new material in receiving mode) / archived PO; 404 \"We could not find a supplier invoice with that ID…\"; 500 \"We could not save your changes right now…\".","item":"object. With per-line 400s: { material_id } of the offending line.","item.material_id":"int | string. Material id of the offending line."},"example_request":{"edit_mode":"processing","items":[{"material_id":112157,"name":"SRC-BT-86CBPR-SG3 BB86-BB92","qty":10,"requested_qty":10,"price":2,"custom_price":2,"subtotal":20},{"material_id":112295,"name":"Atx 27.5 In Vibrant Blue","qty":4,"requested_qty":4,"price":3,"custom_price":3,"subtotal":12}],"subtotal":32,"vat":1.6,"payable":33.6,"discountamount":0},"example":{"success":true,"message":"Your updates were saved.","invoice":{"id":6187,"items_number":2,"subtotal":32,"vat":1.6,"payable":33.6,"discountamount":0,"discountpct":0,"paid":0,"change":0,"note":"","uuid":"52e14591-f11f-430c-8672-aa9412cc3eb9","created_at":"2026-05-19T16:16:14.000Z","customer_id":437,"location_id":907,"info_name":"Faisal Suppliers","info_address":"Industrial Area 2, Sharjah","info_phone":"+971500000003","info_email":"sales@faisal.example","status":"draft","user":"Nexus Cafe","user_id":1201,"table_id":881,"payment_method":null,"order_id":18,"invoice_id":null,"custom_price":null,"your_order_ref":"","supplier_order_ref":"","published_date":null,"is_draft":1,"parent_purchase_uuid":"6f6db90f-2a7d-4910-82e9-3a0dae7e6e70","delivery_date":null,"order_request_uuid":null,"supplier_invoice_number":null,"received_by":null,"received_at":null,"approved_by":null,"approved_at":null,"rejected_by":null,"rejected_at":null,"supplier_invoice_id":3,"delivery_details":null,"is_archive":0},"items":[{"id":14292,"name":"SRC-BT-86CBPR-SG3 BB86-BB92","price":2,"qty":10,"subtotal":20,"purchase_uuid":"52e14591-f11f-430c-8672-aa9412cc3eb9","customer_id":437,"location_id":907,"material_id":112157,"extras":null,"custom_price":2,"product_id":null,"unit":null,"wastage":null,"brand_id":null,"requested_qty":10,"remark":null},{"id":14310,"name":"Atx 27.5 In Vibrant Blue","price":3,"qty":4,"subtotal":12,"purchase_uuid":"52e14591-f11f-430c-8672-aa9412cc3eb9","customer_id":437,"location_id":907,"material_id":112295,"extras":null,"custom_price":3,"product_id":null,"unit":null,"wastage":null,"brand_id":null,"requested_qty":4,"remark":null}]},"master_rules":["Only call it on a draft: get_purchase_order(uuid) first and require is_draft = 1 and is_archive = 0. The API itself does not check is_draft, so on a pending/partially-received PO it would silently change a live order — never do that from MCP.","Always send edit_mode \"processing\" (the dashboard draft editor does). Send the FULL line list: every line you keep (unchanged ones too), new ones added, removed ones left out.","Per line send what the dashboard sends: material_id, name, qty, requested_qty = qty, price, custom_price = price, subtotal = round4(qty × price), brand_id (or null), remark (or null). qty ≥ 0 (use > 0 — a 0 line is kept at 0), price ≥ 0, one line per material.","Totals: VAT% = draft.vat / draft.subtotal × 100 from the stored draft (0 when vat is 0 — VAT switched off); subtotal = Σ line subtotals; vat = round4(subtotal × VAT% / 100); payable = subtotal + vat; discountamount = the draft's current value.","New materials: take them from list_supplier_materials sup=<draft.table_id>&loc=<draft.location_id> (price = purchase_price ?? avg_price). If a material is not linked to the supplier the dashboard offers to link it afterwards — no MCP tool for that; mention it.","Confirming the draft (→ pending PO, supplier emailed), receiving and stock-in remain dashboard actions."]},"parameters":{"type":"object","properties":{"uuid":{"type":"string","description":"uuid of a draft (is_draft = 1) from list_supplier_invoices / create_draft_purchase_order."},"edit_mode":{"type":"string","enum":["processing","receiving"],"description":"\"processing\" = edit the ORDER (use this for drafts, as the dashboard does): lines are replaced by the list you send, qty = requested qty, new materials allowed."},"items":{"type":"array","items":{"type":"object","properties":{"material_id":{"type":"number","description":"Material id."},"qty":{"type":"number","description":"Quantity in the material's unit, ≥ 0 (400 \"Quantity must be a valid number…\" otherwise)."},"price":{"type":"number","description":"Unit price ≥ 0."},"custom_price":{"type":"number","description":"Unit price ≥ 0; wins over price."},"requested_qty":{"type":"number","description":"Ordered quantity."},"subtotal":{"type":"number","description":"Line total = round4(qty × unit price)."},"custom_subtotal":{"type":"string","description":"Alternative line total used only when subtotal is missing."},"brand_id":{"type":"string","description":"Brand id; \"\" clears it."},"name":{"type":"string","description":"Name snapshot — used only for NEW lines (default \"Material\")."},"remark":{"type":"string","description":"Line note; \"\" clears."}},"required":["material_id","qty"],"description":""},"description":"The COMPLETE line list of the draft (at least one; 400 \"Please send at least one line item to update.\")."},"subtotal":{"type":"number","description":"Header subtotal = Σ line subtotals."},"vat":{"type":"number","description":"VAT AMOUNT (not percent) = round4(subtotal × VAT% / 100), VAT% = the draft's stored vat / subtotal × 100 (0 if its vat is 0)."},"payable":{"type":"number","description":"subtotal + vat."},"discountamount":{"type":"number","description":"Header discount amount; the dashboard resends the draft's current value (normally 0)."}},"required":["uuid","items","subtotal","vat","payable"]},"module":"purchase_orders","safety":"write"},{"name":"delete_draft_purchase_order","description":"DELETE /api/supplier-invoices/:uuid. Why: Permanently delete a DRAFT purchase order and its lines (a receive-shortfall draft or a draft generated from a stock order request). The API refuses non-drafts (403), so live POs cannot be deleted. Check the draft with get_purchase_order and confirm with the user first. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_draft_purchase_order\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/supplier-invoices/:uuid","why":"Permanently delete a DRAFT purchase order and its lines (a receive-shortfall draft or a draft generated from a stock order request). The API refuses non-drafts (403), so live POs cannot be deleted. Check the draft with get_purchase_order and confirm with the user first.","response":{"description":"200 → { success: true, message: \"Draft deleted successfully\" }. 404 → { error: \"Purchase order not found\" }. 403 → { error: \"Only draft purchase orders can be deleted\" }. 500 → { error: \"Failed to delete draft\" }. No undo.","fields":{"success":"boolean. true when deleted.","message":"string. \"Draft deleted successfully\".","error":"string. Error only (see description)."},"example":{"success":true,"message":"Draft deleted successfully"},"master_rules":["get_purchase_order(uuid) first: require is_draft = 1, then confirm with the user (PO number, supplier, lines) — the dashboard asks \"This draft purchase request will be permanently deleted and cannot be recovered.\"","Archived drafts are deleted too (no archive check) — mention it if is_archive = 1.","For a draft with parent_purchase_uuid the dashboard also recomputes the parent PO's status afterwards (partially_received → pending when no drafts remain) through a route MCP may not call; the parent status may stay stale until the user acts on it in the dashboard — say so.","Deleting a draft that came from a stock order request (your_order_ref \"REQ-<n>\", no parent) does not change the order request; see list_stock_order_requests.","Nothing about stock changes: drafts never posted stock."]},"parameters":{"type":"object","properties":{"uuid":{"type":"string","description":"Draft uuid (list_supplier_invoices st=draft / create_draft_purchase_order → draft.uuid)."}},"required":["uuid"]},"module":"purchase_orders","safety":"destructive"},{"name":"create_inventory_count","description":"POST /api/inventory-counts/create. Why: Record a physical stock count for one branch as a DRAFT (MCP forces status draft): each line stores counted vs expected quantity and unit cost. Stock is NOT changed — a user must submit and approve the count in the dashboard, which then posts counted − expected as adjustments. Ids/quantities come from list_materials / get_material (material, units, avg_price), get_material_stock or list_materials with loc (expected stock), list_locations (branch). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_inventory_count\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/inventory-counts/create","why":"Record a physical stock count for one branch as a DRAFT (MCP forces status draft): each line stores counted vs expected quantity and unit cost. Stock is NOT changed — a user must submit and approve the count in the dashboard, which then posts counted − expected as adjustments. Ids/quantities come from list_materials / get_material (material, units, avg_price), get_material_stock or list_materials with loc (expected stock), list_locations (branch).","response":{"description":"Always HTTP 200. Success → { status: true, message: \"Inventory count created\", data: { id, start_date, start_time, location_id, name, type, is_partial, status: \"draft\", created_at, updated_at, created_by_user_id, materials } } where materials echoes the request rows. Validation/other errors → { status: false, message } with messages: \"name should not be blank\", \"must enter type and it should be of type number\", \"please enter a valid start_date\", \"please enter a valid start_time\", \"must enter is_partial value and it should only be 0 or 1\", \"must enter status value\", \"must enter materials value as an array of objects …\", or \"Error creating inventory count\" (DB error). Not transactional: header then lines are inserted one by one.","fields":{"status":"boolean. true when created.","message":"string. \"Inventory count created\" or the error text.","data":"object (success only). The created count header plus the request's materials.","data.id":"int. New count id → get_inventory_count / update_inventory_count_materials / add_inventory_count_item.","data.start_date":"string. As sent.","data.start_time":"string. As sent.","data.location_id":"int. Branch id.","data.name":"string. As sent.","data.type":"int. 0 | 1 | 2.","data.is_partial":"0 | 1.","data.status":"string. \"draft\" (forced by MCP).","data.created_at":"number. Unix seconds.","data.updated_at":"number. Unix seconds.","data.created_by_user_id":"int. Session user id.","data.materials":"array. Echo of the materials you sent (not re-read; no line ids — call get_inventory_count for those).","data.materials[].material_id":"Echo.","data.materials[].counted_quantity":"Echo.","data.materials[].expected_quantity":"Echo.","data.materials[].price":"Echo.","data.materials[].name":"Echo."},"master_rules":["Build it like the dashboard create screen (Inventory → Inventory Count → New): step 1 date, time, name, branch; step 2 type (Partial / Full / Count Sheet); step 3 quantities. Body: { name, type, is_partial: type===0 ? 1 : 0, start_date: \"Sep 26, 2026\", start_time: \"03:15 PM\", location_id, materials: [{ material_id, counted_quantity, expected_quantity, price, name }] }.","For each line take from list_materials (loc = the count's branch, stockable materials) or get_material: name, stock → expected_quantity, avg_price (else price) → price, unit / ingredientunit / conversionrate for conversion. get_material_stock gives the branch stock of one material.","Quantities are in the material's STORAGE unit (materials.unit, e.g. Kilogram). The dashboard lets users type storage units plus ingredient units and sends counted = unit_qty + ingredient_qty / conversionrate; do the same conversion (conversionrate = ingredient units per storage unit, from list_materials / get_material).","Never invent counted quantities: only lines the user actually counted. The dashboard sends 0 for uncounted rows of full/sheet counts, which approval turns into a write-down to 0 — do not copy that; list only counted materials (type 0) unless the user confirms zeros.","Lifecycle (dashboard): draft → \"Submit for Approval\" → pending → Approve / Reject / Send Back (→ draft); rejected → draft. APPROVE is what changes stock: for every line it posts counted_quantity − expected_quantity as an inventory ADJUSTMENT movement at the count's branch (price = line price; lines with 0 difference skipped), using the expected_quantity stored in the count, not live stock. The agent cannot submit, approve, reject or send back (change-status is not exposed) — after preparing a draft, tell the user to open Inventory → Inventory Count → the count and use Submit for Approval / Approve. The resulting adjustments are visible in list_inventory_movements (mov_type adjustment, reference \"Inventory count <name> - Approved (ID: <id>)\").","After creating, give the user the count id/name and say it is a draft awaiting Submit for Approval → Approve in the dashboard. Corrections before that: update_inventory_count_materials / update_inventory_count_item / add_inventory_count_item."],"example_request":{"name":"Default location - Sep 26, 2026 03:15 PM","type":0,"is_partial":1,"start_date":"Sep 26, 2026","start_time":"03:15 PM","location_id":907,"materials":[{"material_id":119196,"counted_quantity":48.5,"expected_quantity":50,"price":100,"name":"Test Material"}]},"example":{"status":true,"message":"Inventory count created","data":{"id":478,"start_date":"Sep 26, 2026","start_time":"03:15 PM","location_id":907,"name":"Default location - Sep 26, 2026 03:15 PM","type":0,"is_partial":1,"status":"draft","created_at":1790414100,"updated_at":1790414100,"created_by_user_id":4734,"materials":[{"material_id":119196,"counted_quantity":48.5,"expected_quantity":50,"price":100,"name":"Test Material"}]},"_note":"Code-derived (no write calls made)."}},"parameters":{"type":"object","properties":{"name":{"type":"string","description":"Count name."},"type":{"type":"number","description":"0 = partial (only the materials you list), 1 = full (every stockable material of the branch), 2 = from a count sheet."},"start_date":{"type":"string","description":"Count date as display text (stored verbatim)."},"start_time":{"type":"string","description":"Count time as display text (stored verbatim)."},"is_partial":{"type":"number","description":"1 when type = 0, else 0 (what the dashboard sends)."},"location_id":{"type":"number","description":"ONE branch id (list_locations) — where expected stock is taken and where approval will adjust stock."},"materials":{"type":"array","items":{"type":"object","properties":{"material_id":{"type":"number","description":"Material id (list_materials → data[].id; stockable materials of the count's branch)."},"counted_quantity":{"type":"number","description":"Counted quantity in the STORAGE unit, ≥ 0, ≤ 4 decimals (storage qty + ingredient qty / conversionrate)."},"expected_quantity":{"type":"number","description":"System stock at the count's branch in the storage unit, ≥ 0 (dashboard: the material's stock from list_materials with loc = the branch; or get_material_stock)."},"price":{"type":"number","description":"Unit cost per storage unit, ≥ 0 (dashboard: material avg_price, else price)."},"name":{"type":"string","description":"Material name snapshot (get_material / list_materials name)."}},"required":["material_id","counted_quantity","expected_quantity","price","name"],"description":""},"description":"One row per material, each with EXACTLY these 5 keys (any other key → validation error \"must enter materials value as an array of objects with material_id, cou…"}},"required":["name","type","start_date","start_time","is_partial","location_id","materials"]},"module":"inventory_counts","safety":"write"},{"name":"update_inventory_count_materials","description":"PUT /api/inventory-counts/update-materials. Why: Correct lines already on a DRAFT count — counted, expected, price and name per material (matched by inventory_count_id + material_id). Only updates existing lines: it cannot add (add_inventory_count_item) or remove lines. Use get_inventory_count first; stock only changes when the count is approved in the dashboard. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_inventory_count_materials\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/inventory-counts/update-materials","why":"Correct lines already on a DRAFT count — counted, expected, price and name per material (matched by inventory_count_id + material_id). Only updates existing lines: it cannot add (add_inventory_count_item) or remove lines. Use get_inventory_count first; stock only changes when the count is approved in the dashboard.","response":{"description":"Always HTTP 200. Success → { status: true, message: \"Inventory count updated successfully\", data: { inventory_count_id, materials_updated, materials } }. Errors → { status: false, message, data: null } with messages \"inventory_count_id is required\", \"Invalid inventory_count_id\", \"Materials array is required and must not be empty\", \"Duplicate material_id in materials array is not allowed\", Joi errors (\"please enter valid counted_quantity (non-negative, up to 4 decimal places)\", \"please enter valid expected_quantity …\", \"please enter valid price (non-negative number)\", \"please enter name\", \"must enter valid material_id\"), \"Inventory count not found\", \"Material id <id> is not part of this inventory count. Add it first or use a valid material_id.\".","fields":{"status":"boolean. true when every row was updated.","message":"string. \"Inventory count updated successfully\" or the error text.","data":"object | null. null on error.","data.inventory_count_id":"int. The count id.","data.materials_updated":"int. Number of line rows changed (a material present twice on the count counts twice).","data.materials":"array. The rows as saved (quantities rounded to 6 dp, price to 4 dp, name trimmed).","data.materials[].material_id":"int.","data.materials[].counted_quantity":"number. Storage unit.","data.materials[].expected_quantity":"number. Storage unit.","data.materials[].price":"number.","data.materials[].name":"string."},"master_rules":["Dashboard: on a DRAFT count, only the creator sees editable quantity boxes (storage-unit + ingredient-unit per line) and Save sends { inventory_count_id, materials: EVERY line with material_id, counted_quantity = unit + ingredient / conversionrate, expected_quantity and price unchanged, name }. You may send only the lines you change, but always all 5 keys per line.","Check get_inventory_count → status === \"draft\" first. The API also accepts pending/approved counts, but editing a pending count changes what the approver sees and editing an approved one no longer affects stock (record becomes inconsistent).","Quantities are in the material's STORAGE unit (materials.unit, e.g. Kilogram). The dashboard lets users type storage units plus ingredient units and sends counted = unit_qty + ingredient_qty / conversionrate; do the same conversion (conversionrate = ingredient units per storage unit, from list_materials / get_material).","Lifecycle (dashboard): draft → \"Submit for Approval\" → pending → Approve / Reject / Send Back (→ draft); rejected → draft. APPROVE is what changes stock: for every line it posts counted_quantity − expected_quantity as an inventory ADJUSTMENT movement at the count's branch (price = line price; lines with 0 difference skipped), using the expected_quantity stored in the count, not live stock. The agent cannot submit, approve, reject or send back (change-status is not exposed) — after preparing a draft, tell the user to open Inventory → Inventory Count → the count and use Submit for Approval / Approve. The resulting adjustments are visible in list_inventory_movements (mov_type adjustment, reference \"Inventory count <name> - Approved (ID: <id>)\")."],"example_request":{"inventory_count_id":478,"materials":[{"material_id":119196,"counted_quantity":47,"expected_quantity":50,"price":100,"name":"Test Material"}]},"example":{"status":true,"message":"Inventory count updated successfully","data":{"inventory_count_id":478,"materials_updated":1,"materials":[{"material_id":119196,"counted_quantity":47,"expected_quantity":50,"price":100,"name":"Test Material"}]},"_note":"Code-derived."}},"parameters":{"type":"object","properties":{"inventory_count_id":{"type":"number","description":"Count id (list_inventory_counts / get_inventory_count)."},"materials":{"type":"array","items":{"type":"object","properties":{"material_id":{"type":"number","description":"Material id (list_materials → data[].id; stockable materials of the count's branch)."},"counted_quantity":{"type":"number","description":"Counted quantity in the STORAGE unit, ≥ 0, ≤ 4 decimals (storage qty + ingredient qty / conversionrate)."},"expected_quantity":{"type":"number","description":"System stock at the count's branch in the storage unit, ≥ 0 (dashboard: the material's stock from list_materials with loc = the branch; or get_material_stock)."},"price":{"type":"number","description":"Unit cost per storage unit, ≥ 0 (dashboard: material avg_price, else price)."},"name":{"type":"string","description":"Material name snapshot (get_material / list_materials name)."}},"required":["material_id","counted_quantity","expected_quantity","price","name"],"description":""},"description":"Lines to overwrite (non-empty; no duplicate material_id → \"Duplicate material_id in materials array is not allowed\")."}},"required":["inventory_count_id","materials"]},"module":"inventory_counts","safety":"write"},{"name":"update_inventory_count_item","description":"PUT /api/inventory-counts/update-item-count. Why: Change only the counted quantity of one or more count LINES, addressed by line id (get_inventory_count → materials[].id) + material_id. Lighter than update_inventory_count_materials (no expected/price/name needed). Use on draft counts; stock changes only on dashboard approval. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_inventory_count_item\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/inventory-counts/update-item-count","why":"Change only the counted quantity of one or more count LINES, addressed by line id (get_inventory_count → materials[].id) + material_id. Lighter than update_inventory_count_materials (no expected/price/name needed). Use on draft counts; stock changes only on dashboard approval.","response":{"description":"Always HTTP 200. Success → { status: true, message: \"Item count updated successfully\", data: <the materials array you sent> }. Errors → { status: false, message, data: [] }: \"Materials array is required\", \"Materials must be a non-empty array\", \"Invalid material object at index <i>\", Joi errors (\"please enter valid id (material record id from inventory_materials_count)\", \"please enter valid material_id\", \"please enter valid counted_quantity (must be 0 or greater, up to 4 decimal places)\"), \"Record id <id> does not match material_id <m>. Record not found.\".","fields":{"status":"boolean. true when all rows were updated.","message":"string. \"Item count updated successfully\" or the error text.","data":"array. Echo of the request rows on success; [] on error.","data[].id":"int. Echo: line id.","data[].material_id":"int. Echo.","data[].counted_quantity":"number. Echo (stored rounded to 4 dp)."},"master_rules":["Dashboard: the per-line \"Edit\" on the count detail page (shown for every non-approved count at the user's branch) sends { materials: [{ id: <line id>, material_id, counted_quantity }] } with one storage-unit number. Get line ids from get_inventory_count.","Check status first: only edit draft counts (the API does not check). A pending count is being reviewed; an approved count already adjusted stock.","Quantities are in the material's STORAGE unit (materials.unit, e.g. Kilogram). The dashboard lets users type storage units plus ingredient units and sends counted = unit_qty + ingredient_qty / conversionrate; do the same conversion (conversionrate = ingredient units per storage unit, from list_materials / get_material).","Lifecycle (dashboard): draft → \"Submit for Approval\" → pending → Approve / Reject / Send Back (→ draft); rejected → draft. APPROVE is what changes stock: for every line it posts counted_quantity − expected_quantity as an inventory ADJUSTMENT movement at the count's branch (price = line price; lines with 0 difference skipped), using the expected_quantity stored in the count, not live stock. The agent cannot submit, approve, reject or send back (change-status is not exposed) — after preparing a draft, tell the user to open Inventory → Inventory Count → the count and use Submit for Approval / Approve. The resulting adjustments are visible in list_inventory_movements (mov_type adjustment, reference \"Inventory count <name> - Approved (ID: <id>)\")."],"example_request":{"materials":[{"id":9747,"material_id":119197,"counted_quantity":10.5}]},"example":{"status":true,"message":"Item count updated successfully","data":[{"id":9747,"material_id":119197,"counted_quantity":10.5}],"_note":"Code-derived."}},"parameters":{"type":"object","properties":{"materials":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number","description":"Count LINE id = get_inventory_count → data[0].materials[].id (not the count id, not the material id)."},"material_id":{"type":"number","description":"Material id of that line; must match the line (else \"Record id <id> does not match material_id <m>."},"counted_quantity":{"type":"number","description":"New counted quantity, storage unit, ≥ 0, ≤ 4 decimals."}},"required":["id","material_id","counted_quantity"],"description":""},"description":"Non-empty array of { id, material_id, counted_quantity }."}},"required":["materials"]},"module":"inventory_counts","safety":"write"},{"name":"add_inventory_count_item","description":"POST /api/inventory-counts/add-item. Why: Add material lines to an existing DRAFT count (e.g. an item found on the shelf that was not on the list). Material ids / expected stock / cost from list_materials (loc = the count's branch) or get_material + get_material_stock. No stock change until the count is approved in the dashboard. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"add_inventory_count_item\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/inventory-counts/add-item","why":"Add material lines to an existing DRAFT count (e.g. an item found on the shelf that was not on the list). Material ids / expected stock / cost from list_materials (loc = the count's branch) or get_material + get_material_stock. No stock change until the count is approved in the dashboard.","response":{"description":"Always HTTP 200. Success → { status: true, message: \"Item count saved\", data: [{ material_id, name, price, counted_quantity, expected_quantity }] } (the inserted rows, parsed). Errors → { status: false, message, data: [] }: \"inventory_count_id is required\", \"Invalid inventory_count_id\", \"Materials array is required and must not be empty\", Joi errors (\"must enter valid material_id\", \"please enter valid counted_quantity (non-negative, up to 4 decimal places)\", \"please enter valid expected_quantity …\", \"please enter valid price (non-negative number)\", \"please enter name\"), \"Something went wrong, Item not count saved\" (insert failed; earlier rows may be saved).","fields":{"status":"boolean. true when all lines were inserted.","message":"string. \"Item count saved\" or the error text.","data":"array. Inserted lines (no line ids — call get_inventory_count); [] on error.","data[].material_id":"int.","data[].name":"string.","data[].price":"number.","data[].counted_quantity":"number. Storage unit.","data[].expected_quantity":"number. Storage unit."},"master_rules":["Dashboard: \"Add\" on the count detail page exists only for the creator of a DRAFT PARTIAL count (type 0). It searches stockable materials of the count's branch not yet on the count and sends { inventory_count_id, materials: [{ material_id, counted_quantity (> 0 required in the UI), expected_quantity: material stock, price, name }] }. Follow the same limits.","expected_quantity = the material's stock at the count's branch (list_materials loc=<branch> → stock, or get_material_stock); price = avg_price (the create screen uses avg_price first; the add screen uses price first — prefer avg_price for consistency).","Quantities are in the material's STORAGE unit (materials.unit, e.g. Kilogram). The dashboard lets users type storage units plus ingredient units and sends counted = unit_qty + ingredient_qty / conversionrate; do the same conversion (conversionrate = ingredient units per storage unit, from list_materials / get_material).","Lifecycle (dashboard): draft → \"Submit for Approval\" → pending → Approve / Reject / Send Back (→ draft); rejected → draft. APPROVE is what changes stock: for every line it posts counted_quantity − expected_quantity as an inventory ADJUSTMENT movement at the count's branch (price = line price; lines with 0 difference skipped), using the expected_quantity stored in the count, not live stock. The agent cannot submit, approve, reject or send back (change-status is not exposed) — after preparing a draft, tell the user to open Inventory → Inventory Count → the count and use Submit for Approval / Approve. The resulting adjustments are visible in list_inventory_movements (mov_type adjustment, reference \"Inventory count <name> - Approved (ID: <id>)\")."],"example_request":{"inventory_count_id":478,"materials":[{"material_id":119197,"counted_quantity":3,"expected_quantity":2.5,"price":0.2873,"name":"Test"}]},"example":{"status":true,"message":"Item count saved","data":[{"material_id":119197,"name":"Test","price":0.2873,"counted_quantity":3,"expected_quantity":2.5}],"_note":"Code-derived."}},"parameters":{"type":"object","properties":{"inventory_count_id":{"type":"number","description":"Count id (list_inventory_counts / get_inventory_count)."},"materials":{"type":"array","items":{"type":"object","properties":{"material_id":{"type":"number","description":"Material id (list_materials → data[].id; stockable materials of the count's branch)."},"counted_quantity":{"type":"number","description":"Counted quantity in the STORAGE unit, ≥ 0, ≤ 4 decimals (storage qty + ingredient qty / conversionrate)."},"expected_quantity":{"type":"number","description":"System stock at the count's branch in the storage unit, ≥ 0 (dashboard: the material's stock from list_materials with loc = the branch; or get_material_stock)."},"price":{"type":"number","description":"Unit cost per storage unit, ≥ 0 (dashboard: material avg_price, else price)."},"name":{"type":"string","description":"Material name snapshot (get_material / list_materials name)."}},"required":["material_id","name"],"description":""},"description":"Non-empty array of new lines { material_id, counted_quantity, expected_quantity, price, name }."}},"required":["inventory_count_id","materials"]},"module":"inventory_counts","safety":"write"},{"name":"get_wastage","description":"GET /api/wastage/:id. Why: One wastage batch with all its lines (material, qty, unit, cost). Needed before update_wastage_draft (it replaces the whole batch, so resend everything) or delete_wastage_draft (confirm it is still a draft). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_wastage\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/wastage/:id","why":"One wastage batch with all its lines (material, qty, unit, cost). Needed before update_wastage_draft (it replaces the whole batch, so resend everything) or delete_wastage_draft (confirm it is still a draft).","response":{"description":"200 → { success: true, message, data: [ { header, items } ] }. Not found / other tenant → 500 { success: false, message: \"Failed to get wastage batch\", error: \"Wastage batch not found\" } (verified live; the 404 branch is unreachable).","fields":{"success":"boolean. true when found.","message":"string. \"Wastage batch fetched successfully\".","data":"array. [ { header, items } ] — always one element on success; read data[0].","data[].header":"object. The batch.","data[].header.id":"int. Batch id → get_wastage / update_wastage_draft / delete_wastage_draft.","data[].header.batch_number":"string. \"WB-<year>-<5-digit seq>\", shown as the batch reference.","data[].header.wastage_reason":"string | null. Batch reason (applies to all lines). Dashboard reasons (value → label): expired → Expired, damaged → Damaged, spoiled → Spoiled, quality_issue → Quality Issue, contaminated → Contaminated, other → Other (with \"other\" the typed free text is sent instead). System batches also use e.g. invoice_cancellation, \"Customer complaint\".","data[].header.items_count":"int. Number of lines (distinct materials on create).","data[].header.total_cost":"number. Σ line total_cost as sent by the creator (not recomputed from materials).","data[].header.approval_status":"string. draft | pending | approved | rejected. approved = stock already deducted.","data[].header.requires_approval":"0 | 1. 1 = submit goes to pending (all manual batches); 0 only for batches created already approved.","data[].header.created_by":"int. Creator user id (list_users).","data[].header.approved_by":"int | null. Approver user id; null until approved.","data[].header.batch_notes":"string | null. Free-text note (system batches store e.g. the invoice reference).","data[].header.approval_notes":"string | null. Approver/rejecter note (reject prompt).","data[].header.location_id":"int. Branch the waste happened at (list_locations); stock-out happens here.","data[].header.customer_id":"int. Tenant id. Not needed.","data[].header.submitted_at":"string (ISO) | null. When moved to pending; null for drafts.","data[].header.approved_at":"string (ISO) | null. When approved.","data[].header.created_at":"string (ISO). Creation time (the list's date filter uses it).","data[].header.updated_at":"string (ISO). Last header update.","data[].header.reference_type":"string | null. Origin: manual (dashboard/MCP), invoice_cancellation, invoice_refund, order_cancellation, recipe_inherent, stock_transfer_variance.","data[].header.reference_id":"string | null. Id of the originating document (e.g. invoice uuid); null for manual.","data[].header.brand_id":"int | null. Batch brand (list_brands) — set when every line has the same brand.","data[].header.location_name":"string | null. Branch name (joined).","data[].header.created_by_name":"string | null. Creator username (joined).","data[].header.approved_by_name":"string | null. Approver username (joined).","data[].items":"array. Lines ordered by id.","data[].items[].id":"int. Line id (not needed for updates — update_wastage_draft replaces all lines).","data[].items[].wastage_batch_id":"int. = header.id.","data[].items[].material_id":"int. Material id (get_material).","data[].items[].qty":"number. Wasted quantity in `unit` (normally the ingredient unit).","data[].items[].unit":"string. Unit label as sent (e.g. \"Gram\", \"Pieces\").","data[].items[].unit_price":"number. Cost per `unit`.","data[].items[].total_cost":"number. Line cost as sent (qty × unit_price).","data[].items[].item_notes":"string | null. Line note.","data[].items[].customer_id":"int. Tenant id. Not needed.","data[].items[].created_at":"string (ISO). Line insert time.","data[].items[].brand_id":"int | null. Line brand (list_brands).","data[].items[].material_name":"string | null. Current material name (joined).","data[].items[].brand_name":"string | null. Brand name (joined)."},"master_rules":["The dashboard detail page reads data[0].header and data[0].items and shows batch_number, status pill, location_name, created_at, reason (title-cased), items_count, created_by_name, and a table of material_name, brand_name, qty + unit, unit_price, total_cost, item_notes; totals are recomputed from the lines. approved_by_name / approval_notes are not shown.","Draft batches are editable there (qty, add/remove lines) by users with inventory.wastage.edit, then \"Move to Approval\"; pending batches show Approve / Reject; approved / rejected are read-only.","Lifecycle (dashboard): draft → \"Move to Approval\" (submit) → pending → Approve (needs inventory.wastage.approve) / Reject (optional notes) → approved | rejected. APPROVE is what moves stock: for every line it posts a WASTAGE stock-out at the batch's branch of qty / conversionrate storage units, priced at the material's avg_price. The agent cannot submit, approve or reject (those routes are not exposed; MCP always creates drafts). After preparing a draft tell the user to open Inventory → Wastage → the batch → Move to Approval, then an approver approves. Resulting movements: list_inventory_movements (wastage, reference \"WASTAGE_BATCH_<batch_number>\"). The dashboard's own create screen skips draft and creates pending batches directly.","Units: qty is in the material's INGREDIENT unit (materials.ingredientunit, e.g. Gram) — stock-out on approval = qty / conversionrate storage units. Send unit = the ingredientunit label, unit_price = storage cost (avg_price, else price) / conversionrate, total_cost = qty × unit_price — exactly as the dashboard builds lines. Take ingredientunit, conversionrate, avg_price from list_materials / get_material. (Lines added on the dashboard detail page use the storage unit with rate 1 — a known inconsistency; do not copy it.)"],"example_request":{"id":33},"example":{"success":true,"message":"Wastage batch fetched successfully","data":[{"header":{"id":33,"batch_number":"WB-2026-00030","wastage_reason":"Customer complaint","items_count":7,"total_cost":13.2238,"approval_status":"pending","requires_approval":1,"created_by":4734,"approved_by":null,"batch_notes":"Refund wastage — invoice 79EEB56A-1E47-4DB1-9F24-9234A4FB017E","approval_notes":null,"location_id":907,"customer_id":437,"submitted_at":"2026-07-01T13:17:21.000Z","approved_at":null,"created_at":"2026-07-01T13:17:20.000Z","updated_at":"2026-07-01T13:17:20.000Z","reference_type":"invoice_refund","reference_id":"79EEB56A-1E47-4DB1-9F24-9234A4FB017E","brand_id":null,"location_name":"Default location","created_by_name":"Nexus Cafe","approved_by_name":null},"items":[{"id":121,"wastage_batch_id":33,"material_id":111644,"qty":3,"unit":"Pieces","unit_price":0.4512,"total_cost":1.3536,"item_notes":null,"customer_id":437,"created_at":"2026-07-01T13:17:20.000Z","brand_id":null,"material_name":"[CBSH114I] Caliper Brake Road SHIMANO Ultegra BR-R8000 F/R ","brand_name":null},{"id":122,"wastage_batch_id":33,"material_id":111649,"qty":3,"unit":"Pieces","unit_price":1.001,"total_cost":3.003,"item_notes":null,"customer_id":437,"created_at":"2026-07-01T13:17:20.000Z","brand_id":null,"material_name":"[CBSH001FI] Caliper Brake Road SHIMANO 105 BR-R7000 IBRR7000AF82XL","brand_name":null}]}]}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Wastage batch id (list_wastage → data.data[].id)."}},"required":["id"]},"module":"wastage","safety":"read"},{"name":"create_wastage_draft","description":"POST /api/wastage. Why: Record wasted stock (expired / damaged / spoiled …) for one branch as a DRAFT wastage batch — MCP forces approval_status draft and strips skip_stock_movement, so no stock moves. A user then submits it and an approver approves it in the dashboard, which deducts the stock. Material ids, units and cost from list_materials / get_material; branch from list_locations; brand from list_brands. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_wastage_draft\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/wastage","why":"Record wasted stock (expired / damaged / spoiled …) for one branch as a DRAFT wastage batch — MCP forces approval_status draft and strips skip_stock_movement, so no stock moves. A user then submits it and an approver approves it in the dashboard, which deducts the stock. Material ids, units and cost from list_materials / get_material; branch from list_locations; brand from list_brands.","response":{"description":"201 → { success: true, message: \"Wastage batch created successfully\", data: { id, batch_number, approval_status: \"draft\" } }. Errors → 500 { success: false, error } e.g. \"No items provided\", \"No valid wastage items provided\" (no line has material_id), or a DB error text (missing location_id / unit / unit_price → NOT NULL violation). Transactional: nothing is saved on error. requires_approval is set to 1, submitted_at null.","fields":{"success":"boolean. true when created.","message":"string. \"Wastage batch created successfully\" (absent on errors).","error":"string. Error text (errors only).","data":"object (success only).","data.id":"int. New batch id → get_wastage / update_wastage_draft / delete_wastage_draft.","data.batch_number":"string. \"WB-<year>-<seq>\".","data.approval_status":"string. \"draft\"."},"master_rules":["Build it like the dashboard (Inventory → Wastage → New: branch → pick materials + qty + one reason for all lines → review): { wastage_reason, location_id, batch_notes?, brand_id?, items: [{ material_id, qty, unit, unit_price, total_cost, item_notes?, brand_id? }] }. The dashboard requires a branch, ≥ 1 line, qty > 0, no duplicate material, and a reason.","Units: qty is in the material's INGREDIENT unit (materials.ingredientunit, e.g. Gram) — stock-out on approval = qty / conversionrate storage units. Send unit = the ingredientunit label, unit_price = storage cost (avg_price, else price) / conversionrate, total_cost = qty × unit_price — exactly as the dashboard builds lines. Take ingredientunit, conversionrate, avg_price from list_materials / get_material. (Lines added on the dashboard detail page use the storage unit with rate 1 — a known inconsistency; do not copy it.)","Say it is a draft: nothing is deducted until someone opens the batch in the dashboard, clicks Move to Approval, and an approver approves. Give the batch_number.","Lifecycle (dashboard): draft → \"Move to Approval\" (submit) → pending → Approve (needs inventory.wastage.approve) / Reject (optional notes) → approved | rejected. APPROVE is what moves stock: for every line it posts a WASTAGE stock-out at the batch's branch of qty / conversionrate storage units, priced at the material's avg_price. The agent cannot submit, approve or reject (those routes are not exposed; MCP always creates drafts). After preparing a draft tell the user to open Inventory → Wastage → the batch → Move to Approval, then an approver approves. Resulting movements: list_inventory_movements (wastage, reference \"WASTAGE_BATCH_<batch_number>\"). The dashboard's own create screen skips draft and creates pending batches directly."],"example_request":{"wastage_reason":"expired","location_id":907,"items":[{"material_id":119196,"qty":500,"unit":"Gram","unit_price":0.1,"total_cost":50,"item_notes":"Past use-by date"}]},"example":{"success":true,"message":"Wastage batch created successfully","data":{"id":34,"batch_number":"WB-2026-00031","approval_status":"draft"},"_note":"Code-derived (no write calls made)."}},"parameters":{"type":"object","properties":{"wastage_reason":{"type":"string","description":"Batch reason (one for all lines)."},"location_id":{"type":"number","description":"ONE branch id (list_locations) where the waste happened; stock-out on approval happens here."},"batch_notes":{"type":"string","description":"Free-text note for the batch."},"brand_id":{"type":"number","description":"Batch brand (list_brands)."},"items":{"type":"array","items":{"type":"object","properties":{"material_id":{"type":"number","description":"Material id (list_materials → data[].id; the dashboard offers stockable materials of the branch)."},"qty":{"type":"number","description":"Quantity wasted in the material's ingredient unit, > 0 (dashboard minimum 0.000001)."},"unit":{"type":"string","description":"Unit label of qty — the material's ingredientunit (fallback unit)."},"unit_price":{"type":"number","description":"Cost per ingredient unit = (avg_price, else price) / conversionrate, ≥ 0."},"total_cost":{"type":"number","description":"qty × unit_price."},"item_notes":{"type":"string","description":"Optional line note."},"brand_id":{"type":"number","description":"Optional brand of the line (list_brands)."}},"required":["material_id","qty","unit","unit_price","total_cost"],"description":""},"description":"Non-empty list of lines (empty → 500 \"No items provided\")."}},"required":["wastage_reason","location_id","items"]},"module":"wastage","safety":"write"},{"name":"update_wastage_draft","description":"PUT /api/wastage/:id. Why: Change a DRAFT wastage batch (reason, branch, notes, brand, lines). NOT a patch: header fields you omit become null and the lines are replaced by exactly the items you send — get_wastage first and resend everything with your change. The API rejects non-draft batches. No stock change. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_wastage_draft\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/wastage/:id","why":"Change a DRAFT wastage batch (reason, branch, notes, brand, lines). NOT a patch: header fields you omit become null and the lines are replaced by exactly the items you send — get_wastage first and resend everything with your change. The API rejects non-draft batches. No stock change.","response":{"description":"200 → { success: true, message: \"Wastage batch updated successfully\", data: { wastage_reason, items_count, total_cost, batch_notes, location_id, brand_id } } (the header values written; brand_id absent when not sent). 500 → { success: false, message } with \"Wastage batch not found\", \"Only draft wastage batches can be edited\", or a DB error. Transactional.","fields":{"success":"boolean. true when saved.","message":"string. \"Wastage batch updated successfully\" or the error text.","data":"object (success only). Header values written.","data.wastage_reason":"string | null.","data.items_count":"int. Number of lines sent.","data.total_cost":"number. Σ items[].total_cost.","data.batch_notes":"string | null.","data.location_id":"int | null.","data.brand_id":"int | null. Absent when not sent (stored as null)."},"master_rules":["Dashboard: only DRAFT batches are editable (detail page, inventory.wastage.edit); it sends { wastage_reason, location_id, batch_notes, brand_id } copied from the header plus ALL lines { material_id, qty, unit, unit_price, total_cost = qty × unit_price, item_notes, brand_id }. Do the same from get_wastage.","Units: qty is in the material's INGREDIENT unit (materials.ingredientunit, e.g. Gram) — stock-out on approval = qty / conversionrate storage units. Send unit = the ingredientunit label, unit_price = storage cost (avg_price, else price) / conversionrate, total_cost = qty × unit_price — exactly as the dashboard builds lines. Take ingredientunit, conversionrate, avg_price from list_materials / get_material. (Lines added on the dashboard detail page use the storage unit with rate 1 — a known inconsistency; do not copy it.)","Lifecycle (dashboard): draft → \"Move to Approval\" (submit) → pending → Approve (needs inventory.wastage.approve) / Reject (optional notes) → approved | rejected. APPROVE is what moves stock: for every line it posts a WASTAGE stock-out at the batch's branch of qty / conversionrate storage units, priced at the material's avg_price. The agent cannot submit, approve or reject (those routes are not exposed; MCP always creates drafts). After preparing a draft tell the user to open Inventory → Wastage → the batch → Move to Approval, then an approver approves. Resulting movements: list_inventory_movements (wastage, reference \"WASTAGE_BATCH_<batch_number>\"). The dashboard's own create screen skips draft and creates pending batches directly."],"example_request":{"id":34,"wastage_reason":"expired","location_id":907,"items":[{"material_id":119196,"qty":750,"unit":"Gram","unit_price":0.1,"total_cost":75,"item_notes":"Past use-by date"}]},"example":{"success":true,"message":"Wastage batch updated successfully","data":{"wastage_reason":"expired","items_count":1,"total_cost":75,"batch_notes":null,"location_id":907,"brand_id":null},"_note":"Code-derived."}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Wastage batch id (list_wastage / get_wastage) whose approval_status is draft."},"wastage_reason":{"type":"string","description":"Batch reason; omitted → null."},"location_id":{"type":"number","description":"Branch id; omitted → null → DB error (NOT NULL)."},"batch_notes":{"type":"string","description":"Note; omitted → null (resend header.batch_notes to keep it)."},"brand_id":{"type":"number","description":"Batch brand; omitted → null (resend header.brand_id when it is not null to keep it)."},"items":{"type":"array","items":{"type":"object","properties":{"material_id":{"type":"number","description":"Material id (list_materials → data[].id; the dashboard offers stockable materials of the branch)."},"qty":{"type":"number","description":"Quantity wasted in the material's ingredient unit, > 0 (dashboard minimum 0.000001)."},"unit":{"type":"string","description":"Unit label of qty — the material's ingredientunit (fallback unit)."},"unit_price":{"type":"number","description":"Cost per ingredient unit = (avg_price, else price) / conversionrate, ≥ 0."},"total_cost":{"type":"number","description":"qty × unit_price."},"item_notes":{"type":"string","description":"Optional line note."},"brand_id":{"type":"number","description":"Optional brand of the line (list_brands)."}},"required":["material_id","qty","unit","unit_price","total_cost"],"description":""},"description":"ALL lines of the batch — FULL REPLACE: existing lines are deleted and these inserted; omitted/[] → the batch ends with 0 lines and total 0."}},"required":["id","wastage_reason","location_id","items"]},"module":"wastage","safety":"write"},{"name":"delete_wastage_draft","description":"DELETE /api/wastage/:id. Why: Permanently delete a DRAFT wastage batch and its lines (hard delete). The API does NOT check the status — it would also delete pending or approved batches without reversing their stock-out — so confirm approval_status is draft with get_wastage first. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_wastage_draft\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/wastage/:id","why":"Permanently delete a DRAFT wastage batch and its lines (hard delete). The API does NOT check the status — it would also delete pending or approved batches without reversing their stock-out — so confirm approval_status is draft with get_wastage first.","response":{"description":"200 → { success: true, message: \"Wastage batch deleted successfully\", data: { success: true } }. Not found / other tenant → 500 { success: false, message: \"Not found\", error: \"Not found\" }. Transactional. No restore.","fields":{"success":"boolean. true when deleted.","message":"string. \"Wastage batch deleted successfully\" | \"Not found\" | DB error text.","data":"object (success only).","data.success":"boolean. true.","error":"string. Error text (errors only)."},"master_rules":["Call get_wastage first and delete ONLY when header.approval_status === \"draft\"; the dashboard's delete handler also refuses non-drafts (\"Only draft wastage batches can be deleted\") and asks for confirmation — though its delete button is currently not rendered, so users cannot delete from the UI.","Deleting an approved batch would erase the record while the stock stays deducted (movements remain in list_inventory_movements) — never do it; for a wrong approved batch the user must correct stock in the dashboard.","Lifecycle (dashboard): draft → \"Move to Approval\" (submit) → pending → Approve (needs inventory.wastage.approve) / Reject (optional notes) → approved | rejected. APPROVE is what moves stock: for every line it posts a WASTAGE stock-out at the batch's branch of qty / conversionrate storage units, priced at the material's avg_price. The agent cannot submit, approve or reject (those routes are not exposed; MCP always creates drafts). After preparing a draft tell the user to open Inventory → Wastage → the batch → Move to Approval, then an approver approves. Resulting movements: list_inventory_movements (wastage, reference \"WASTAGE_BATCH_<batch_number>\"). The dashboard's own create screen skips draft and creates pending batches directly."],"example_request":{"id":34},"example":{"success":true,"message":"Wastage batch deleted successfully","data":{"success":true}}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Wastage batch id (list_wastage / get_wastage) whose approval_status is draft."}},"required":["id"]},"module":"wastage","safety":"destructive"},{"name":"get_stock_transfer","description":"GET /api/stock-transfer-invoices/:id. Why: One transfer with its lines (material, requested/approved/dispatched/received qty, variance, cost, source on-hand and shortfall), status history, linked draft POs and parent/child re-supply transfers — the dashboard transfer page. Call it before update_stock_transfer / update_stock_transfer_items (status must be pending or draft, and you need items[].id), and to explain where a transfer stands. Ids come from list_stock_transfers; material ids link to get_material / list_materials; on_hand is the source branch stock (same as list_inventory for that branch). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_stock_transfer\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/stock-transfer-invoices/:id","why":"One transfer with its lines (material, requested/approved/dispatched/received qty, variance, cost, source on-hand and shortfall), status history, linked draft POs and parent/child re-supply transfers — the dashboard transfer page. Call it before update_stock_transfer / update_stock_transfer_items (status must be pending or draft, and you need items[].id), and to explain where a transfer stands. Ids come from list_stock_transfers; material ids link to get_material / list_materials; on_hand is the source branch stock (same as list_inventory for that branch).","response":{"description":"200 → { success: true, message, data: [ transfer ] }: the stock_transfer row (+ branch names, *_by_username) with items[] (each line enriched with po_meta, on_hand at the source branch and shortfall), history[] (newest first), purchase_orders[] and variance/auto-create links. 404 { success:false, message:\"Stock transfer not found\" }. 500 { success:false, message:\"Failed to get stock transfer\", error }.","fields":{"success":"true when found.","message":"string. \"Stock transfer fetched successfully\".","data":"array with exactly ONE transfer object (read data[0]).","data[].id":"int. Transfer id (numeric). Pass as `id` to get_stock_transfer / update_stock_transfer / update_stock_transfer_items. The dashboard shows invoice_id (fallback id) as \"Order ID\".","data[].items_number":"int | null. Number of lines at the last save.","data[].subtotal":"number | null. Sum of line subtotals (qty × price) — cost value of the transfer, not a sale.","data[].vat":"number | null. VAT AMOUNT (not a percent): subtotal × branch VAT % when the creator ticked \"Include VAT\", else 0. update_stock_transfer_items recomputes it as subtotal × 5% whenever it was > 0.","data[].payable":"number | null. subtotal + vat (transfer value incl. VAT).","data[].discountamount":"number | null. Legacy header column, always 0/null for transfers. Not needed.","data[].discountpct":"number | null. Legacy, 0/null. Not needed.","data[].paid":"number | null. Legacy, 0/null. Not needed.","data[].change":"number | null. Legacy, 0/1/null. Not needed.","data[].note":"string | null. Free-text header note (varchar 300); the dashboard create form sends \"\".","data[].uuid":"string | null. Client-generated transfer uuid; links lines (items[].stock_transfer_uuid) and history. Not accepted by get_stock_transfer (numeric id only).","data[].created_at":"string (ISO, UTC). Creation time; the list is sorted by it (newest first) and from/to filter on its UTC date.","data[].customer_id":"int. Tenant id. Not needed.","data[].from_location_id":"int. SOURCE branch (list_locations) — stock leaves here on dispatch.","data[].info_name":"string | null. Username of the creator (dashboard fills it from the logged-in user); the list shows it as the \"User\" column with an avatar.","data[].info_address":"string | null. Legacy, empty. Not needed.","data[].info_email":"string | null. Creator email.","data[].info_phone":"string | null. Legacy, empty. Not needed.","data[].status":"string. draft | pending | approved | dispatched | received | completed | rejected (legacy rows may say delivered / cancelled). See master_rules for the lifecycle.","data[].user":"string | null. Creator username.","data[].table_id":"int | null. Legacy (order-request accept flow wrote a user id here). Not needed.","data[].payment_method":"string | null. Legacy. Not needed.","data[].order_id":"int | null. Legacy sequence from the old order-request accept flow. Not needed.","data[].invoice_id":"int | null. Per-branch running number (per creator branch). Dashboard \"Order ID\" column = invoice_id || id.","data[].to_location_id":"int. DESTINATION branch (list_locations) — stock arrives here on receive.","data[].customer_location_id":"int | null. Branch of the user/API key that created the transfer (not necessarily the source).","data[].email":"string | null. Legacy email column. Not needed.","data[].delivery_date":"string (ISO) | null. Delivery date set at creation (dashboard date picker, optional).","data[].user_id":"int | null. Creator user id.","data[].is_edit":"string | null. Legacy. Not needed.","data[].username":"string | null. Creator username.","data[].transfer_no":"string | null. Human transfer number \"STR-<year>-<5 digits>\" (global sequence). Quote it to the user.","data[].requested_by":"int. User id that created the request.","data[].approved_by":"int | null. User id that approved (dashboard).","data[].dispatched_by":"int | null. User id that dispatched (dashboard).","data[].received_by":"int | null. User id that received (dashboard).","data[].requested_at":"string (ISO, UTC). Request time.","data[].approved_at":"string (ISO) | null. Approval time.","data[].dispatched_at":"string (ISO) | null. Dispatch time (source stock deducted).","data[].received_at":"string (ISO) | null. Receive time (destination stock added).","data[].notes":"string | null. INTERNAL JSON metadata (auto-created / variance-draft links), not a user note — parsed into transfer_meta by get_stock_transfer. Never write it.","data[].priority":"string | null. Optional label (low | normal | high | urgent); the dashboard never sets it.","data[].expected_delivery_date":"string (ISO date) | null. Optional; the dashboard never sets it.","data[].updated_at":"string (ISO). Last update time.","data[].rejected_by":"int | null. User id that rejected (dashboard).","data[].rejected_at":"string (ISO) | null. Rejection time.","data[].parent_transfer_id":"int | null. For a variance re-supply draft: the transfer whose shortfall it re-sends.","data[].from_location":"string | null. Source branch name.","data[].to_location":"string | null. Destination branch name.","data[].requested_by_username":"string | null. Username of requested_by.","data[].approved_by_username":"string | null. Username of approved_by.","data[].dispatched_by_username":"string | null. Username of dispatched_by.","data[].received_by_username":"string | null. Username of received_by.","data[].items":"array. Transfer lines (stock_transfer_items, all columns) enriched below.","data[].items[].id":"int. Line id. Send it back as items[].id in update_stock_transfer_items to change this line (omit a line to delete it).","data[].items[].name":"string | null. Material name snapshot.","data[].items[].price":"number | null. Unit cost at creation (material avg/purchase price, per storage unit).","data[].items[].qty":"number. Requested quantity, in the material's storage unit (`unit`).","data[].items[].subtotal":"number | null. qty × price.","data[].items[].stock_transfer_uuid":"string. uuid of the parent transfer.","data[].items[].location_id":"int | null. Branch of the creating user (create) or source branch (lines added later). Not needed — use the header from/to.","data[].items[].customer_id":"int. Tenant id. Not needed.","data[].items[].material_id":"int. Material id (list_materials / get_material).","data[].items[].extras":"string | null. Raw JSON meta (PO / resupply links); parsed into po_meta.","data[].items[].is_edit":"string. \"0\". Legacy. Not needed.","data[].items[].approved_qty":"number | null. Quantity approved at the source (dashboard Approve step; may be less than qty, 0 or negative). null until approved.","data[].items[].dispatched_qty":"number | null. Quantity dispatched = approved_qty, set on dispatch (this is when source stock is deducted).","data[].items[].received_qty":"number | null. Quantity counted in at the destination (dashboard Receive step; destination stock is added by this).","data[].items[].variance_qty":"number | null. received_qty − dispatched_qty (negative = shortfall). Non-zero needs variance_reason.","data[].items[].variance_reason":"string | null. Receiver's reason for a variance.","data[].items[].unit":"string | null. Storage-unit label the qty is in (e.g. \"Kilogram\").","data[].items[].unit_id":"int | null. Unit id (often null).","data[].items[].item_status":"string | null. Unused (null). Not needed.","data[].items[].notes":"string | null. Line note/remark.","data[].items[].created_at":"string (ISO). Line creation time.","data[].items[].updated_at":"string (ISO). Line update time.","data[].items[].stock_transfer_id":"int | null. Unused (null). Not needed.","data[].items[].brand_id":"int | null. Brand the stock belongs to (multi-brand materials); passed to the inventory movement.","data[].items[].disposition_type":"string | null. wastage | return | overage_ack — how the source resolved a receive variance (dashboard Variances page).","data[].items[].disposition_status":"string | null. pending (variance awaiting resolution) | resolved | null.","data[].items[].resolved_by":"int | null. User who resolved the variance.","data[].items[].resolved_at":"string (ISO) | null. When the variance was resolved.","data[].items[].disposition_notes":"string | null. Resolution note.","data[].items[].po_meta":"object. Parsed `extras` (keys such as purchase_uuid, po_number, inherited_from_transfer_id, inherited_from_item_id, source_variance_qty); {} when none.","data[].items[].purchase_uuid":"string | null. Draft PO created for this line's shortfall (dashboard \"Generate POs\").","data[].items[].supplier_id":"int | null. Supplier of that PO line.","data[].items[].supplier_name":"string | null. Supplier name of that PO line.","data[].items[].po_number":"string | null. PO number of that PO line.","data[].items[].moved_to_transfer_id":"int | null. Transfer this line was moved to (PO receive flow).","data[].items[].moved_to_transfer_no":"string | null. transfer_no of that transfer.","data[].items[].storageunit":"string | null. Material storage unit (current code adds it; not yet returned by dev1).","data[].items[].on_hand":"number. Current stock of the material at the SOURCE branch (materials_locations.stock; negative stock is allowed).","data[].items[].shortfall":"number. (approved_qty ?? qty) − on_hand; > 0 means the source lacks stock (dashboard offers POs for it).","data[].items[].resupply_draft_created":"true | absent. Present when a variance re-supply draft transfer was created for this line.","data[].items[].resupply_draft_transfer_id":"int | absent. Id of that re-supply draft transfer.","data[].items[].resupply_draft_transfer_uuid":"string | absent. Its uuid.","data[].items[].resupply_draft_transfer_no":"string | absent. Its transfer_no.","data[].items[].resupply_draft_status":"string | absent. Its status.","data[].history":"array. Status/edit log, newest first.","data[].history[].id":"int. History row id.","data[].history[].stock_transfer_id":"int. Always 0 (unused). Not needed.","data[].history[].changed_by":"int. User id who made the change.","data[].history[].changed_at":"string (ISO). When.","data[].history[].notes":"string. Human text, e.g. \"Transfer moved from APPROVED to DISPATCHED. …\".","data[].history[].metadata":"object. { action (create | edit_transfer_request | update_qty_price | move_to_approved | move_to_dispatched | update_received_qty | move_to_received | complete | status_change | move_back_to_pending | auto_create_from_po | …), old_status, new_status / status, user, items_count, … }.","data[].history[].customer_id":"int. Tenant id. Not needed.","data[].history[].stock_transfer_uuid":"string. Transfer uuid.","data[].history[].created_at":"string (ISO). Same as changed_at.","data[].history[].changed_by_username":"string | null. Username of changed_by.","data[].purchase_orders":"array. Draft POs generated from this transfer's shortfall lines (dashboard \"Generate POs\"); [] when none.","data[].purchase_orders[].uuid":"string. PO uuid (get_purchase_order).","data[].purchase_orders[].po_number":"string. PO number.","data[].purchase_orders[].supplier":"string. Supplier name.","data[].purchase_orders[].supplier_id":"int. Supplier id (list_suppliers).","data[].purchase_orders[].created_by":"string. Creator username.","data[].purchase_orders[].created_at":"string (ISO). Creation time.","data[].purchase_orders[].items_count":"int. Lines on the PO.","data[].purchase_orders[].subtotal":"number. PO subtotal.","data[].purchase_orders[].vat":"number. PO VAT amount.","data[].purchase_orders[].total":"number. PO payable.","data[].purchase_orders[].status":"string. Always \"Draft\" here.","data[].purchase_orders[].delivery_date":"string | null. PO delivery date.","data[].purchase_orders[].destination_location_id":"int. Branch the PO delivers to.","data[].is_auto_created":"boolean. true = created automatically from POs on another transfer (see source_transfer_no, originating_po_numbers).","data[].is_variance_draft":"boolean. true = re-supply draft for another transfer's receive shortfall (see parent_transfer).","data[].source_transfer_id":"int | null. Transfer this one was auto-created from.","data[].source_transfer_no":"string | null. Its transfer_no.","data[].source_transfer_uuid":"string | null. Its uuid.","data[].parent_transfer":"object | null. { id, uuid, transfer_no, status } of the parent (variance drafts).","data[].parent_transfer.id":"int. Parent transfer id.","data[].parent_transfer.uuid":"string | null. Parent uuid.","data[].parent_transfer.transfer_no":"string | null. Parent transfer_no.","data[].parent_transfer.status":"string | null. Parent status.","data[].child_transfers":"array. Transfers whose parent_transfer_id is this one (re-supply drafts).","data[].child_transfers[].id":"int. Child transfer id.","data[].child_transfers[].uuid":"string. Child uuid.","data[].child_transfers[].transfer_no":"string. Child transfer_no.","data[].child_transfers[].status":"string. Child status.","data[].child_transfers[].created_at":"string (ISO). Child creation time.","data[].child_transfers[].parent_transfer_id":"int. = this transfer's id.","data[].originating_po_uuids":"string[]. PO uuids that caused an auto-created transfer.","data[].originating_po_numbers":"string[]. Their PO numbers (dashboard shows them as chips).","data[].transfer_meta":"object. Parsed `notes` JSON (is_auto_created, source_transfer_*, originating_po_uuids, is_variance_draft, parent_transfer_*); {} when none.","data[].linked_po_count":"int. Number of distinct POs linked from lines (items[].purchase_uuid)."},"example_request":{"id":7772},"example":{"success":true,"message":"Stock transfer fetched successfully","data":[{"id":7772,"items_number":1,"subtotal":1101.36,"vat":55.068,"payable":1156.428,"discountamount":0,"discountpct":0,"paid":0,"change":0,"note":"","uuid":"799537a3-c13f-400b-a494-78aeb1a5bd79","created_at":"2026-07-31T06:17:04.000Z","customer_id":437,"from_location_id":907,"info_name":"Nexus Cafe","info_address":null,"info_email":"admin@devnexus.co","info_phone":null,"status":"completed","user":"Nexus Cafe","table_id":null,"payment_method":null,"order_id":null,"invoice_id":41,"to_location_id":909,"customer_location_id":907,"email":null,"delivery_date":null,"user_id":4734,"is_edit":null,"username":"Nexus Cafe","transfer_no":"STR-2026-01623","requested_by":4734,"approved_by":4734,"dispatched_by":4734,"received_by":4734,"requested_at":"2026-07-31T06:17:05.000Z","approved_at":"2026-07-31T06:17:21.000Z","dispatched_at":"2026-07-31T06:17:25.000Z","received_at":"2026-07-31T06:17:38.000Z","notes":null,"priority":null,"expected_delivery_date":null,"updated_at":"2026-07-31T06:17:45.000Z","rejected_by":null,"rejected_at":null,"parent_transfer_id":null,"from_location":"Default location","to_location":"Iqbal Town","requested_by_username":"Nexus Cafe","approved_by_username":"Nexus Cafe","dispatched_by_username":"Nexus Cafe","received_by_username":"Nexus Cafe","items":[{"id":67284,"name":"Test Material","price":11.0136,"qty":100,"subtotal":1101.36,"stock_transfer_uuid":"799537a3-c13f-400b-a494-78aeb1a5bd79","location_id":907,"customer_id":437,"material_id":119196,"extras":null,"is_edit":"0","approved_qty":100,"dispatched_qty":100,"received_qty":50,"variance_qty":-50,"variance_reason":"lost","unit":"Kilogram","unit_id":null,"item_status":null,"notes":null,"created_at":"2026-07-31T06:17:04.000Z","updated_at":"2026-07-31T06:17:37.000Z","stock_transfer_id":null,"brand_id":null,"disposition_type":null,"disposition_status":null,"resolved_by":null,"resolved_at":null,"disposition_notes":null,"po_meta":{},"purchase_uuid":null,"supplier_id":null,"supplier_name":null,"po_number":null,"moved_to_transfer_id":null,"moved_to_transfer_no":null,"on_hand":-21311072.05389,"shortfall":21311172.05389,"resupply_draft_created":true,"resupply_draft_transfer_id":7773,"resupply_draft_transfer_uuid":"808adb34-cc06-4c22-93bc-0589f3101788","resupply_draft_transfer_no":"STR-2026-01624","resupply_draft_status":"draft"}],"history":[{"id":9460,"stock_transfer_id":0,"changed_by":4734,"changed_at":"2026-07-31T06:17:45.000Z","notes":"Transfer completed","metadata":{"user":"Nexus Cafe","action":"complete","status":"completed","old_status":"received"},"customer_id":437,"stock_transfer_uuid":"799537a3-c13f-400b-a494-78aeb1a5bd79","created_at":"2026-07-31T06:17:45.000Z","changed_by_username":"Nexus Cafe"},{"id":9458,"stock_transfer_id":0,"changed_by":4734,"changed_at":"2026-07-31T06:17:38.000Z","notes":"Variance shortfall draft STR-2026-01624 created and linked to this transfer.","metadata":{"user":"Nexus Cafe","action":"variance_draft_created","shortfall_items":1,"child_transfer_id":7773,"child_transfer_no":"STR-2026-01624","child_transfer_uuid":"808adb34-cc06-4c22-93bc-0589f3101788"},"customer_id":437,"stock_transfer_uuid":"799537a3-c13f-400b-a494-78aeb1a5bd79","created_at":"2026-07-31T06:17:38.000Z","changed_by_username":"Nexus Cafe"},{"id":9456,"stock_transfer_id":0,"changed_by":4734,"changed_at":"2026-07-31T06:17:37.000Z","notes":"Transfer moved from DISPATCHED to RECEIVED. All items received with variance adjustments.","metadata":{"user":"Nexus Cafe","action":"move_to_received","new_status":"received","old_status":"dispatched","items_count":1},"customer_id":437,"stock_transfer_uuid":"799537a3-c13f-400b-a494-78aeb1a5bd79","created_at":"2026-07-31T06:17:37.000Z","changed_by_username":"Nexus Cafe"},{"id":9455,"stock_transfer_id":0,"changed_by":4734,"changed_at":"2026-07-31T06:17:25.000Z","notes":"Transfer moved from APPROVED to DISPATCHED. Approved quantities copied to dispatched quantities.","metadata":{"user":"Nexus Cafe","action":"move_to_dispatched","new_status":"dispatched","old_status":"approved","items_count":1},"customer_id":437,"stock_transfer_uuid":"799537a3-c13f-400b-a494-78aeb1a5bd79","created_at":"2026-07-31T06:17:25.000Z","changed_by_username":"Nexus Cafe"},{"id":9454,"stock_transfer_id":0,"changed_by":4734,"changed_at":"2026-07-31T06:17:21.000Z","notes":"Transfer moved from PENDING to APPROVED. All items approved.","metadata":{"user":"Nexus Cafe","action":"move_to_approved","new_status":"approved","old_status":"pending","items_count":1},"customer_id":437,"stock_transfer_uuid":"799537a3-c13f-400b-a494-78aeb1a5bd79","created_at":"2026-07-31T06:17:21.000Z","changed_by_username":"Nexus Cafe"},{"id":9453,"stock_transfer_id":0,"changed_by":4734,"changed_at":"2026-07-31T06:17:04.000Z","notes":"Stock transfer created","metadata":{"user":"Nexus Cafe","action":"create","status":"pending","items_number":1},"customer_id":437,"stock_transfer_uuid":"799537a3-c13f-400b-a494-78aeb1a5bd79","created_at":"2026-07-31T06:17:04.000Z","changed_by_username":"Nexus Cafe"}],"purchase_orders":[],"is_auto_created":false,"is_variance_draft":false,"source_transfer_id":null,"source_transfer_no":null,"source_transfer_uuid":null,"parent_transfer":null,"child_transfers":[{"id":7773,"uuid":"808adb34-cc06-4c22-93bc-0589f3101788","transfer_no":"STR-2026-01624","status":"draft","created_at":"2026-07-31T06:17:38.000Z","parent_transfer_id":7772}],"originating_po_uuids":[],"originating_po_numbers":[],"transfer_meta":{},"linked_po_count":0}]},"master_rules":["The dashboard transfer page (Inventory → Transfer Request → eye icon) loads this by id and shows: transfer_no, status, From = from_location → To = to_location, requested/approved/dispatched/received timestamps with *_by_username, info_name / info_email, subtotal / vat / payable, auto-created chip with source_transfer_no and originating_po_numbers, parent/child re-supply links, linked purchase_orders, and the lines table (material name, unit, requested qty, approved_qty, dispatched_qty, received_qty, variance_qty + variance_reason, on_hand, notes) plus the history timeline (history[].notes / metadata.action).","Lifecycle as the dashboard shows it (status pill): draft (only variance re-supply drafts) → pending (request created) → approved (source manager sets approved_qty per line and clicks Approve) → dispatched (Move to Dispatch: approved_qty copied to dispatched_qty and stock is DEDUCTED at from_location_id, TRANSFERRED_OUT movement) → received (destination enters received_qty per line + variance_reason when it differs; stock is ADDED at to_location_id, TRANSFERRED_IN movement) → completed (Complete button). pending can be rejected; approved can be moved back to pending. The MCP can only create and edit pending/draft transfers — approve, dispatch, receive, complete, reject and variance resolution happen in the dashboard (Inventory → Transfer Request → transfer page / Variances page).","Before editing: status must be pending (or draft). For pending, update_stock_transfer_items takes the FULL line list — copy every items[].id + material_id + qty (+ price) and change what you need; any existing line you leave out is deleted.","Variance: a received line with variance_qty ≠ 0 gets disposition_status = pending until the source resolves it (wastage / return / overage_ack) on the dashboard Variances page; a shortfall can spawn a re-supply draft (child_transfers, resupply_draft_* on the line). The MCP cannot resolve variances.","on_hand / shortfall are live source-branch stock (can be negative — negative stock is allowed); a positive shortfall means the source cannot cover the line — the dashboard offers \"Generate POs\" (draft POs per supplier, listed in purchase_orders). With the MCP use create_draft_purchase_order for the source branch instead.","Stock does not move when a transfer is created or edited: list_inventory / get_material_stock / list_inventory_movements only change after the dashboard dispatches (source −) and receives (destination +) it."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Numeric transfer id (list_stock_transfers data.data[].id)."}},"required":["id"]},"module":"stock_transfers","safety":"read"},{"name":"create_stock_transfer","description":"POST /api/stock-transfer-invoices. Why: Create an inter-branch stock transfer request (materials from one branch to another) in status pending — the same document the dashboard \"Add New Transfer\" wizard creates. No stock moves: the source approves, dispatches and the destination receives it in the dashboard. Branch ids from list_locations (must be two different branches the key may use); lines from list_materials (qty in storage unit, avg_price as cost); check source stock with list_inventory / get_material_stock. For buying from a supplier use create_draft_purchase_order instead; for a branch asking head office for goods the dashboard uses order requests (create_stock_order_request). Response data is null — find the new transfer with list_stock_transfers (status=pending, newest first, match uuid). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_stock_transfer\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/stock-transfer-invoices","why":"Create an inter-branch stock transfer request (materials from one branch to another) in status pending — the same document the dashboard \"Add New Transfer\" wizard creates. No stock moves: the source approves, dispatches and the destination receives it in the dashboard. Branch ids from list_locations (must be two different branches the key may use); lines from list_materials (qty in storage unit, avg_price as cost); check source stock with list_inventory / get_material_stock. For buying from a supplier use create_draft_purchase_order instead; for a branch asking head office for goods the dashboard uses order requests (create_stock_order_request).","response":{"description":"201 → { success: true, message: \"Stock transfer created successfully\", data: null } (the model returns null for inserts, so there is no id in the response). Server sets transfer_no (STR-<year>-NNNNN), invoice_id, requested_by/at = the API key user, customer_location_id = that user's branch, and writes a \"Stock transfer created\" history row. Errors → 500 { success: false, error: <message> } (e.g. items missing → \"Cannot read properties of undefined (reading 'length')\"); the transaction is rolled back.","fields":{"success":"true on success; false on error.","message":"string. \"Stock transfer created successfully\" (absent on error).","data":"null. No id/transfer_no is returned — look the transfer up with list_stock_transfers (match uuid).","error":"string. Only on 500: the error message."},"example_request":{"uuid":"3f0c2a8e-8d0b-4c8e-9a51-2f7b8f7d1c11","from_location_id":907,"to_location_id":909,"items":[{"material_id":119196,"name":"Test Material","qty":20,"price":11.0136,"subtotal":220.272,"unit":"Kilogram"}],"subtotal":220.272,"vat":11.0136,"payable":231.2856,"note":""},"example":{"success":true,"message":"Stock transfer created successfully","data":null},"master_rules":["Dashboard form (Transfer Request → Add New Transfer, 3 steps): 1) Location — From defaults to the user's branch (or ?loc=), To defaults to the user's branch; Continue is blocked while From = To. 2) Stock Material — add materials from the stockable material list (list_materials); each line = material_id, name, qty (> 0, storage unit), price = material avg_price (fallback price), subtotal = qty × price, unit = storageunit, unit_id, brand_id, notes \"\"; price/subtotal are editable; a material can be added only once; the cart cannot be empty. 3) Review — shows Subtotal, VAT (<branch VAT %>) and Total Payable, optional delivery date, then posts { items, status: \"pending\", uuid: uuidv4(), info_name, info_email, from_location_id, to_location_id, delivery_date, subtotal, vat, payable, discountamount 0, discountpct 0, change 0, paid 0, note \"\" }. Build the same body.","Always generate a fresh uuid, send qty in the storage unit (list_materials unit), price = avg_price, subtotal = qty × price, and compute vat (amount) and payable yourself — the server does not add VAT.","The MCP creates the transfer as pending. What happens next is dashboard-only: the source branch sets approved_qty per line and Approves, then Moves to Dispatch (stock deducted at from_location_id), the destination enters received_qty (+ variance reason) and Receives (stock added at to_location_id), then Complete. Tell the user the transfer is waiting for approval in Inventory → Transfer Request.","Stock does not move when a transfer is created or edited: list_inventory / get_material_stock / list_inventory_movements only change after the dashboard dispatches (source −) and receives (destination +) it.","Only use branches the key allows (list_locations); from/to are not clamped by the MCP, but the list only shows transfers touching the user's allowed branches.","To edit before approval use update_stock_transfer_items (lines) / update_stock_transfer (header). Stock order requests (create_stock_order_request) are a different document: a branch asking for materials that the dashboard turns into draft POs."]},"parameters":{"type":"object","properties":{"uuid":{"type":"string","description":"New random UUID v4 you generate (the dashboard does uuidv4())."},"from_location_id":{"type":"number","description":"SOURCE branch id (list_locations) — stock will leave here on dispatch."},"to_location_id":{"type":"number","description":"DESTINATION branch id (list_locations) — stock will arrive here on receive."},"items":{"type":"array","items":{"type":"object","properties":{"material_id":{"type":"number","description":"Material id to move (list_materials / get_material)."},"name":{"type":"string","description":"Material name snapshot shown on the transfer (send the material name; stored as-is)."},"qty":{"type":"number","description":"Quantity to transfer, in the material's STORAGE unit (the unit list_materials shows); > 0."},"price":{"type":"number","description":"Unit cost per storage unit."},"subtotal":{"type":"number","description":"Line value."},"unit":{"type":"string","description":"Storage-unit label, e.g."},"unit_id":{"type":"number","description":"Unit id if known."},"brand_id":{"type":"number","description":"Brand the stock belongs to, for multi-brand materials (dashboard: the single brand, the brand filter, or the picked brand)."},"notes":{"type":"string","description":"Line note."},"remark":{"type":"string","description":"Alias of notes (used only when notes is empty)."}},"required":["material_id","qty"],"description":""},"description":"Lines to transfer (at least one; the server crashes with 500 when items is missing)."},"subtotal":{"type":"number","description":"Sum of line subtotals."},"vat":{"type":"number","description":"VAT AMOUNT (not percent): subtotal × branch VAT % when \"Include VAT\" is ticked (dashboard default ticked, VAT % from the source branch config, 5 if unset), els…"},"payable":{"type":"number","description":"subtotal + vat."},"note":{"type":"string","description":"Header note, max 300 chars."},"delivery_date":{"type":"string","description":"Planned delivery date YYYY-MM-DD (dashboard optional date picker)."},"expected_delivery_date":{"type":"string","description":"Expected delivery date YYYY-MM-DD."},"priority":{"type":"string","enum":["low","normal","high","urgent"],"description":"Priority label."},"info_name":{"type":"string","description":"Requester name shown as \"User\" in the list."},"info_email":{"type":"string","description":"Requester email."}},"required":["uuid","from_location_id","to_location_id","items"]},"module":"stock_transfers","safety":"write"},{"name":"update_stock_transfer","description":"PUT /api/stock-transfer-invoices/update/:id. Why: Edit the header of a PENDING (or draft) transfer: note, delivery date, expected delivery date, priority. Lines are edited with update_stock_transfer_items. Get the id and check status with list_stock_transfers / get_stock_transfer first; approved/dispatched/received transfers are rejected by the server. No stock moves. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_stock_transfer\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/stock-transfer-invoices/update/:id","why":"Edit the header of a PENDING (or draft) transfer: note, delivery date, expected delivery date, priority. Lines are edited with update_stock_transfer_items. Get the id and check status with list_stock_transfers / get_stock_transfer first; approved/dispatched/received transfers are rejected by the server. No stock moves.","response":{"description":"200 → { success: true, message: \"Stock transfer request updated successfully\", data: { transfer: <stock_transfer row> } }. Every header field is written as `body value || current value`. Errors → 500 { success: false, message }: \"Stock transfer not found\", or \"Cannot update transfer in <status> status. Only pending, draft transfers, or approved transfers being moved back to pending can be updated.\"","fields":{"success":"true on success; false on error.","message":"string. \"Stock transfer request updated successfully\", or the error text on 500.","data":"object. { transfer }.","data.transfer":"object. The raw stock_transfer row after the update (SELECT *; no branch names, no items).","data.transfer.id":"int. Transfer id (numeric). Pass as `id` to get_stock_transfer / update_stock_transfer / update_stock_transfer_items. The dashboard shows invoice_id (fallback id) as \"Order ID\".","data.transfer.items_number":"int | null. Number of lines at the last save.","data.transfer.subtotal":"number | null. Sum of line subtotals (qty × price) — cost value of the transfer, not a sale.","data.transfer.vat":"number | null. VAT AMOUNT (not a percent): subtotal × branch VAT % when the creator ticked \"Include VAT\", else 0. update_stock_transfer_items recomputes it as subtotal × 5% whenever it was > 0.","data.transfer.payable":"number | null. subtotal + vat (transfer value incl. VAT).","data.transfer.discountamount":"number | null. Legacy header column, always 0/null for transfers. Not needed.","data.transfer.discountpct":"number | null. Legacy, 0/null. Not needed.","data.transfer.paid":"number | null. Legacy, 0/null. Not needed.","data.transfer.change":"number | null. Legacy, 0/1/null. Not needed.","data.transfer.note":"string | null. Free-text header note (varchar 300); the dashboard create form sends \"\".","data.transfer.uuid":"string | null. Client-generated transfer uuid; links lines (items[].stock_transfer_uuid) and history. Not accepted by get_stock_transfer (numeric id only).","data.transfer.created_at":"string (ISO, UTC). Creation time; the list is sorted by it (newest first) and from/to filter on its UTC date.","data.transfer.customer_id":"int. Tenant id. Not needed.","data.transfer.from_location_id":"int. SOURCE branch (list_locations) — stock leaves here on dispatch.","data.transfer.info_name":"string | null. Username of the creator (dashboard fills it from the logged-in user); the list shows it as the \"User\" column with an avatar.","data.transfer.info_address":"string | null. Legacy, empty. Not needed.","data.transfer.info_email":"string | null. Creator email.","data.transfer.info_phone":"string | null. Legacy, empty. Not needed.","data.transfer.status":"string. draft | pending | approved | dispatched | received | completed | rejected (legacy rows may say delivered / cancelled). See master_rules for the lifecycle.","data.transfer.user":"string | null. Creator username.","data.transfer.table_id":"int | null. Legacy (order-request accept flow wrote a user id here). Not needed.","data.transfer.payment_method":"string | null. Legacy. Not needed.","data.transfer.order_id":"int | null. Legacy sequence from the old order-request accept flow. Not needed.","data.transfer.invoice_id":"int | null. Per-branch running number (per creator branch). Dashboard \"Order ID\" column = invoice_id || id.","data.transfer.to_location_id":"int. DESTINATION branch (list_locations) — stock arrives here on receive.","data.transfer.customer_location_id":"int | null. Branch of the user/API key that created the transfer (not necessarily the source).","data.transfer.email":"string | null. Legacy email column. Not needed.","data.transfer.delivery_date":"string (ISO) | null. Delivery date set at creation (dashboard date picker, optional).","data.transfer.user_id":"int | null. Creator user id.","data.transfer.is_edit":"string | null. Legacy. Not needed.","data.transfer.username":"string | null. Creator username.","data.transfer.transfer_no":"string | null. Human transfer number \"STR-<year>-<5 digits>\" (global sequence). Quote it to the user.","data.transfer.requested_by":"int. User id that created the request.","data.transfer.approved_by":"int | null. User id that approved (dashboard).","data.transfer.dispatched_by":"int | null. User id that dispatched (dashboard).","data.transfer.received_by":"int | null. User id that received (dashboard).","data.transfer.requested_at":"string (ISO, UTC). Request time.","data.transfer.approved_at":"string (ISO) | null. Approval time.","data.transfer.dispatched_at":"string (ISO) | null. Dispatch time (source stock deducted).","data.transfer.received_at":"string (ISO) | null. Receive time (destination stock added).","data.transfer.notes":"string | null. INTERNAL JSON metadata (auto-created / variance-draft links), not a user note — parsed into transfer_meta by get_stock_transfer. Never write it.","data.transfer.priority":"string | null. Optional label (low | normal | high | urgent); the dashboard never sets it.","data.transfer.expected_delivery_date":"string (ISO date) | null. Optional; the dashboard never sets it.","data.transfer.updated_at":"string (ISO). Last update time.","data.transfer.rejected_by":"int | null. User id that rejected (dashboard).","data.transfer.rejected_at":"string (ISO) | null. Rejection time.","data.transfer.parent_transfer_id":"int | null. For a variance re-supply draft: the transfer whose shortfall it re-sends."},"example_request":{"id":7776,"note":"Deliver before lunch","delivery_date":"2026-09-28","priority":"high"},"example":{"success":true,"message":"Stock transfer request updated successfully","data":{"transfer":{"id":7776,"items_number":2,"subtotal":5841.84,"vat":292.092,"payable":6133.932,"discountamount":0,"discountpct":0,"paid":0,"change":0,"note":"Deliver before lunch","uuid":"fe1ecd13-f615-4326-b6a1-482b5353a3c6","created_at":"2026-08-12T10:48:07.000Z","customer_id":437,"from_location_id":907,"info_name":"Nexus Cafe","info_address":null,"info_email":"admin@devnexus.co","info_phone":null,"status":"pending","user":"Nexus Cafe","table_id":null,"payment_method":null,"order_id":null,"invoice_id":44,"to_location_id":922,"customer_location_id":907,"email":null,"delivery_date":"2026-09-28T00:00:00.000Z","user_id":4734,"is_edit":null,"username":"Nexus Cafe","transfer_no":"STR-2026-01628","requested_by":4734,"approved_by":null,"dispatched_by":null,"received_by":null,"requested_at":"2026-08-12T10:48:07.000Z","approved_at":null,"dispatched_at":null,"received_at":null,"notes":null,"priority":"high","expected_delivery_date":null,"updated_at":"2026-08-12T10:48:20.000Z","rejected_by":null,"rejected_at":null,"parent_transfer_id":null}}},"master_rules":["The dashboard has no header-edit form: it calls this handler only through its status route (PUT /stock-transfer-invoices/status/:id with { status }) for \"Create transfer\" (draft → pending) and \"Move Back to Pending\" (approved → pending). The MCP uses it only for note / delivery_date / expected_delivery_date / priority on pending or draft transfers.","Call get_stock_transfer first and only proceed when status is pending or draft; otherwise tell the user the transfer is already approved/dispatched and must be handled in the dashboard.","Values cannot be cleared (\"\" or omitted keeps the old value).","Stock does not move when a transfer is created or edited: list_inventory / get_material_stock / list_inventory_movements only change after the dashboard dispatches (source −) and receives (destination +) it."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Numeric transfer id (list_stock_transfers data.data[].id)."},"note":{"type":"string","description":"New header note (max 300)."},"delivery_date":{"type":"string","description":"New delivery date YYYY-MM-DD."},"expected_delivery_date":{"type":"string","description":"New expected delivery date YYYY-MM-DD."},"priority":{"type":"string","enum":["low","normal","high","urgent"],"description":"New priority label."}},"required":["id"]},"module":"stock_transfers","safety":"write"},{"name":"update_stock_transfer_items","description":"PUT /api/stock-transfer-invoices/items/:id. Why: Edit the lines of a PENDING (or draft) transfer: change qty / price / note, add a material, or remove a line — the dashboard transfer page \"Save\" before approval. Read the lines with get_stock_transfer (items[].id, material_id, qty, price) and send the full list back; materials from list_materials. On approved/dispatched transfers this same route is the dashboard dispatch/receive action, so never call it unless status is pending or draft. No stock moves while pending. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_stock_transfer_items\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/stock-transfer-invoices/items/:id","why":"Edit the lines of a PENDING (or draft) transfer: change qty / price / note, add a material, or remove a line — the dashboard transfer page \"Save\" before approval. Read the lines with get_stock_transfer (items[].id, material_id, qty, price) and send the full list back; materials from list_materials. On approved/dispatched transfers this same route is the dashboard dispatch/receive action, so never call it unless status is pending or draft. No stock moves while pending.","response":{"description":"200 → { success: true, message: \"Transfer items updated successfully\", data: { transfer: { <stock_transfer row + from_location, to_location, *_by_username>, items: [raw lines] } } }. After a pending/draft edit the server recomputes subtotal = Σ line subtotals, vat = subtotal × 5% if the transfer already had VAT (else 0), payable = subtotal + vat, items_number = number of lines sent, and logs a history row. Errors → 500 { success: false, message, error } with e.g. \"Stock transfer not found\", \"Unauthorized access to this transfer\", \"Items are required\", \"Item ID and material ID are required\" (draft), \"Material ID is required for new items\", \"Quantity must be greater than 0 for new items\", \"Quantity must be greater than 0\", \"Material with ID <id> not found\", \"Cannot update items for transfer in <status> status\"; the transaction is rolled back.","fields":{"success":"true on success; false on error.","message":"string. \"Transfer items updated successfully\", or the error text on 500.","error":"string. Only on 500: same text as message.","data":"object. { transfer }.","data.transfer":"object. The transfer row (+ from_location, to_location, *_by_username) after the update, with raw lines.","data.transfer.id":"int. Transfer id (numeric). Pass as `id` to get_stock_transfer / update_stock_transfer / update_stock_transfer_items. The dashboard shows invoice_id (fallback id) as \"Order ID\".","data.transfer.items_number":"int | null. Number of lines at the last save.","data.transfer.subtotal":"number | null. Sum of line subtotals (qty × price) — cost value of the transfer, not a sale.","data.transfer.vat":"number | null. VAT AMOUNT (not a percent): subtotal × branch VAT % when the creator ticked \"Include VAT\", else 0. update_stock_transfer_items recomputes it as subtotal × 5% whenever it was > 0.","data.transfer.payable":"number | null. subtotal + vat (transfer value incl. VAT).","data.transfer.discountamount":"number | null. Legacy header column, always 0/null for transfers. Not needed.","data.transfer.discountpct":"number | null. Legacy, 0/null. Not needed.","data.transfer.paid":"number | null. Legacy, 0/null. Not needed.","data.transfer.change":"number | null. Legacy, 0/1/null. Not needed.","data.transfer.note":"string | null. Free-text header note (varchar 300); the dashboard create form sends \"\".","data.transfer.uuid":"string | null. Client-generated transfer uuid; links lines (items[].stock_transfer_uuid) and history. Not accepted by get_stock_transfer (numeric id only).","data.transfer.created_at":"string (ISO, UTC). Creation time; the list is sorted by it (newest first) and from/to filter on its UTC date.","data.transfer.customer_id":"int. Tenant id. Not needed.","data.transfer.from_location_id":"int. SOURCE branch (list_locations) — stock leaves here on dispatch.","data.transfer.info_name":"string | null. Username of the creator (dashboard fills it from the logged-in user); the list shows it as the \"User\" column with an avatar.","data.transfer.info_address":"string | null. Legacy, empty. Not needed.","data.transfer.info_email":"string | null. Creator email.","data.transfer.info_phone":"string | null. Legacy, empty. Not needed.","data.transfer.status":"string. draft | pending | approved | dispatched | received | completed | rejected (legacy rows may say delivered / cancelled). See master_rules for the lifecycle.","data.transfer.user":"string | null. Creator username.","data.transfer.table_id":"int | null. Legacy (order-request accept flow wrote a user id here). Not needed.","data.transfer.payment_method":"string | null. Legacy. Not needed.","data.transfer.order_id":"int | null. Legacy sequence from the old order-request accept flow. Not needed.","data.transfer.invoice_id":"int | null. Per-branch running number (per creator branch). Dashboard \"Order ID\" column = invoice_id || id.","data.transfer.to_location_id":"int. DESTINATION branch (list_locations) — stock arrives here on receive.","data.transfer.customer_location_id":"int | null. Branch of the user/API key that created the transfer (not necessarily the source).","data.transfer.email":"string | null. Legacy email column. Not needed.","data.transfer.delivery_date":"string (ISO) | null. Delivery date set at creation (dashboard date picker, optional).","data.transfer.user_id":"int | null. Creator user id.","data.transfer.is_edit":"string | null. Legacy. Not needed.","data.transfer.username":"string | null. Creator username.","data.transfer.transfer_no":"string | null. Human transfer number \"STR-<year>-<5 digits>\" (global sequence). Quote it to the user.","data.transfer.requested_by":"int. User id that created the request.","data.transfer.approved_by":"int | null. User id that approved (dashboard).","data.transfer.dispatched_by":"int | null. User id that dispatched (dashboard).","data.transfer.received_by":"int | null. User id that received (dashboard).","data.transfer.requested_at":"string (ISO, UTC). Request time.","data.transfer.approved_at":"string (ISO) | null. Approval time.","data.transfer.dispatched_at":"string (ISO) | null. Dispatch time (source stock deducted).","data.transfer.received_at":"string (ISO) | null. Receive time (destination stock added).","data.transfer.notes":"string | null. INTERNAL JSON metadata (auto-created / variance-draft links), not a user note — parsed into transfer_meta by get_stock_transfer. Never write it.","data.transfer.priority":"string | null. Optional label (low | normal | high | urgent); the dashboard never sets it.","data.transfer.expected_delivery_date":"string (ISO date) | null. Optional; the dashboard never sets it.","data.transfer.updated_at":"string (ISO). Last update time.","data.transfer.rejected_by":"int | null. User id that rejected (dashboard).","data.transfer.rejected_at":"string (ISO) | null. Rejection time.","data.transfer.parent_transfer_id":"int | null. For a variance re-supply draft: the transfer whose shortfall it re-sends.","data.transfer.from_location":"string | null. Source branch name.","data.transfer.to_location":"string | null. Destination branch name.","data.transfer.requested_by_username":"string | null. Username of requested_by.","data.transfer.approved_by_username":"string | null. Username of approved_by.","data.transfer.dispatched_by_username":"string | null. Username of dispatched_by.","data.transfer.received_by_username":"string | null. Username of received_by.","data.transfer.items":"array. All lines after the update (raw stock_transfer_items rows, no on_hand/po_meta).","data.transfer.items[].id":"int. Line id. Send it back as items[].id in update_stock_transfer_items to change this line (omit a line to delete it).","data.transfer.items[].name":"string | null. Material name snapshot.","data.transfer.items[].price":"number | null. Unit cost at creation (material avg/purchase price, per storage unit).","data.transfer.items[].qty":"number. Requested quantity, in the material's storage unit (`unit`).","data.transfer.items[].subtotal":"number | null. qty × price.","data.transfer.items[].stock_transfer_uuid":"string. uuid of the parent transfer.","data.transfer.items[].location_id":"int | null. Branch of the creating user (create) or source branch (lines added later). Not needed — use the header from/to.","data.transfer.items[].customer_id":"int. Tenant id. Not needed.","data.transfer.items[].material_id":"int. Material id (list_materials / get_material).","data.transfer.items[].extras":"string | null. Raw JSON meta (PO / resupply links); parsed into po_meta.","data.transfer.items[].is_edit":"string. \"0\". Legacy. Not needed.","data.transfer.items[].approved_qty":"number | null. Quantity approved at the source (dashboard Approve step; may be less than qty, 0 or negative). null until approved.","data.transfer.items[].dispatched_qty":"number | null. Quantity dispatched = approved_qty, set on dispatch (this is when source stock is deducted).","data.transfer.items[].received_qty":"number | null. Quantity counted in at the destination (dashboard Receive step; destination stock is added by this).","data.transfer.items[].variance_qty":"number | null. received_qty − dispatched_qty (negative = shortfall). Non-zero needs variance_reason.","data.transfer.items[].variance_reason":"string | null. Receiver's reason for a variance.","data.transfer.items[].unit":"string | null. Storage-unit label the qty is in (e.g. \"Kilogram\").","data.transfer.items[].unit_id":"int | null. Unit id (often null).","data.transfer.items[].item_status":"string | null. Unused (null). Not needed.","data.transfer.items[].notes":"string | null. Line note/remark.","data.transfer.items[].created_at":"string (ISO). Line creation time.","data.transfer.items[].updated_at":"string (ISO). Line update time.","data.transfer.items[].stock_transfer_id":"int | null. Unused (null). Not needed.","data.transfer.items[].brand_id":"int | null. Brand the stock belongs to (multi-brand materials); passed to the inventory movement.","data.transfer.items[].disposition_type":"string | null. wastage | return | overage_ack — how the source resolved a receive variance (dashboard Variances page).","data.transfer.items[].disposition_status":"string | null. pending (variance awaiting resolution) | resolved | null.","data.transfer.items[].resolved_by":"int | null. User who resolved the variance.","data.transfer.items[].resolved_at":"string (ISO) | null. When the variance was resolved.","data.transfer.items[].disposition_notes":"string | null. Resolution note."},"example_request":{"id":7770,"items":[{"id":67280,"material_id":120802,"qty":80,"price":0.214,"notes":"reduced"},{"material_id":119196,"qty":10,"price":11.0136}]},"example":{"success":true,"message":"Transfer items updated successfully","data":{"transfer":{"id":7770,"items_number":2,"subtotal":127.256,"vat":6.3628,"payable":133.6188,"discountamount":0,"discountpct":0,"paid":0,"change":0,"note":"","uuid":"51ed4a86-e033-45a1-9a34-795bb89eeb2f","created_at":"2026-07-30T07:07:35.000Z","customer_id":437,"from_location_id":907,"info_name":"Nexus Cafe","info_address":"","info_email":"admin@devnexus.co","info_phone":"","status":"pending","user":"Nexus Cafe","table_id":null,"payment_method":null,"order_id":null,"invoice_id":40,"to_location_id":908,"customer_location_id":907,"email":"admin@devnexus.co","delivery_date":null,"user_id":4734,"is_edit":null,"username":null,"transfer_no":"STR-2026-01621","requested_by":4734,"approved_by":null,"dispatched_by":null,"received_by":null,"requested_at":"2026-07-30T07:07:36.000Z","approved_at":null,"dispatched_at":null,"received_at":null,"notes":"{\"is_auto_created\":true,\"source_transfer_id\":7769,\"source_transfer_no\":\"STR-2026-01620\",\"source_transfer_uuid\":\"f6248006-ef35-4098-a3a7-88130bf7a8f0\",\"originating_po_uuids\":[\"1ba0b8cd-8495-4e43-80c9-f74dd58214f9\"],\"created_at\":\"2026-07-30T07:07:35.982Z\"}","priority":null,"expected_delivery_date":null,"updated_at":"2026-07-30T07:07:35.000Z","rejected_by":null,"rejected_at":null,"parent_transfer_id":null,"from_location":"Default location","to_location":"Johar Town","requested_by_username":"Nexus Cafe","approved_by_username":null,"dispatched_by_username":null,"received_by_username":null,"items":[{"id":67280,"name":"Gotcha Recipe","price":0.214,"qty":80,"subtotal":17.12,"stock_transfer_uuid":"51ed4a86-e033-45a1-9a34-795bb89eeb2f","location_id":907,"customer_id":437,"material_id":120802,"extras":"{\"inherited_from_transfer_id\":7769,\"inherited_po_uuid\":\"1ba0b8cd-8495-4e43-80c9-f74dd58214f9\",\"inherited_po_number\":\"PO-006919\"}","is_edit":"0","approved_qty":100,"dispatched_qty":null,"received_qty":null,"variance_qty":null,"variance_reason":null,"unit":"Kilogram","unit_id":null,"item_status":null,"notes":"reduced","created_at":"2026-07-30T07:07:35.000Z","updated_at":"2026-07-30T07:07:35.000Z","stock_transfer_id":null,"brand_id":null,"disposition_type":null,"disposition_status":null,"resolved_by":null,"resolved_at":null,"disposition_notes":null},{"id":67300,"name":"Test Material","price":11.0136,"qty":10,"subtotal":110.136,"stock_transfer_uuid":"51ed4a86-e033-45a1-9a34-795bb89eeb2f","location_id":907,"customer_id":437,"material_id":119196,"extras":null,"is_edit":"0","approved_qty":null,"dispatched_qty":null,"received_qty":null,"variance_qty":null,"variance_reason":null,"unit":"Kilogram","unit_id":null,"item_status":null,"notes":null,"created_at":"2026-07-30T07:07:35.000Z","updated_at":"2026-07-30T07:07:35.000Z","stock_transfer_id":null,"brand_id":null,"disposition_type":null,"disposition_status":null,"resolved_by":null,"resolved_at":null,"disposition_notes":null}]}}},"master_rules":["Dashboard transfer page, status pending: the lines table lets the user edit qty, price and notes, add a material (\"Add Material\" dialog, stockable materials of the source branch; a material already on the transfer is refused) and remove lines; Save sends { items: [{ id (existing lines), material_id, qty, price, subtotal = qty × price, notes, approved_qty (only users with approve permission) }] } to this route. Approve sends the same plus move_to_approved: true. Move to Dispatch sends { items: [], move_to_dispatched: true }. Receive (status dispatched) sends lines with received_qty, variance_reason and move_to_received. Draft (variance re-supply) sends { id, material_id, qty, notes } per line.","MCP use: only when get_stock_transfer shows status pending or draft. Start from its items[] (id, material_id, qty, price), change what the user asked, send the whole list — a line you drop is deleted. Never send approved_qty, received_qty, variance_reason or any move_to_* flag.","Quantities are in the material's storage unit; price is the unit cost (list_materials avg_price for new lines, or omit to use the material price).","VAT is recomputed as a flat 5% of the subtotal when the transfer had VAT (the branch VAT setting is not used here), else 0.","Lifecycle as the dashboard shows it (status pill): draft (only variance re-supply drafts) → pending (request created) → approved (source manager sets approved_qty per line and clicks Approve) → dispatched (Move to Dispatch: approved_qty copied to dispatched_qty and stock is DEDUCTED at from_location_id, TRANSFERRED_OUT movement) → received (destination enters received_qty per line + variance_reason when it differs; stock is ADDED at to_location_id, TRANSFERRED_IN movement) → completed (Complete button). pending can be rejected; approved can be moved back to pending. The MCP can only create and edit pending/draft transfers — approve, dispatch, receive, complete, reject and variance resolution happen in the dashboard (Inventory → Transfer Request → transfer page / Variances page).","Stock does not move when a transfer is created or edited: list_inventory / get_material_stock / list_inventory_movements only change after the dashboard dispatches (source −) and receives (destination +) it."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Numeric transfer id (list_stock_transfers / get_stock_transfer)."},"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number","description":"Existing line id (get_stock_transfer items[].id) to update."},"material_id":{"type":"number","description":"Material id (list_materials)."},"qty":{"type":"number","description":"Quantity in the material's storage unit, > 0 (pending: 0/negative → error)."},"price":{"type":"number","description":"Unit cost."},"subtotal":{"type":"number","description":"Only used for NEW lines (omitted → price × qty); for existing lines the server recomputes price × qty."},"name":{"type":"string","description":"New lines only, fallback when the material has no name."},"unit":{"type":"string","description":"New lines only: storage-unit label; omitted → the material's unit."},"unit_id":{"type":"number","description":"New lines only: unit id; omitted → the material's unit_id."},"notes":{"type":"string","description":"Line note; \"\"/null clears it."},"remark":{"type":"string","description":"Alias of notes (used when notes is omitted)."}},"required":["material_id","qty"],"description":""},"description":"The COMPLETE line list after your change (non-empty)."}},"required":["id","items"]},"module":"stock_transfers","safety":"write"},{"name":"get_stock_order_request","description":"GET /api/stock-order-request/:uuid. Why: One order request with its lines (material, qty in storage unit, unit cost, supplier) and the draft PO it produced — the dashboard request detail page. Call it before update_stock_order_request (status must be Pending; you must resend every line). uuids come from list_stock_order_requests; material ids link to get_material, supplier ids to list_suppliers, purchase_uuid to get_purchase_order. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_stock_order_request\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/stock-order-request/:uuid","why":"One order request with its lines (material, qty in storage unit, unit cost, supplier) and the draft PO it produced — the dashboard request detail page. Call it before update_stock_order_request (status must be Pending; you must resend every line). uuids come from list_stock_order_requests; material ids link to get_material, supplier ids to list_suppliers, purchase_uuid to get_purchase_order.","response":{"description":"200 → [ request ] (one element) with items[] (current lines joined to materials), current_location_id and tax; 200 [] when the uuid does not exist. Errors → 500 { error: \"Failed to load order request\" }.","fields":{"[]":"array with ONE request object, or [] when the uuid is unknown (no 404).","[].id":"int. Request number (invoice_id, fallback row id) — dashboard shows \"REQ-<id>\". Not the uuid.","[].username":"string | null. Creator username.","[].subtotal":"number | null. Σ line qty × price.","[].vat":"number | null. VAT AMOUNT (subtotal × branch VAT % when Include VAT is ticked, else 0).","[].email":"string | null. Legacy. Not needed.","[].delivery_date":"string (ISO) | null. Requested delivery date/time.","[].payable":"number | null. subtotal + vat — dashboard \"Total\" column.","[].change":"number | null. Legacy supplier-status flag (0). Not needed.","[].note":"string | null. Header note.","[].uuid":"string. Request uuid — pass to get_stock_order_request / update_stock_order_request.","[].reject_reason":"string | null. Reason text of a legacy warehouse rejection. Not set by the current flow.","[].created_at":"string. Creation DATE in the tenant timezone (midnight, time dropped).","[].customer_id":"int. Tenant id. Not needed.","[].to_location_id":"int | null. Destination branch; the dashboard sets it = from_location_id (the requesting branch) — and to the PO destination on the Draft PO page.","[].from_location_id":"int | null. Requesting branch (list_locations); the list is scoped to the user's allowed branches by this column.","[].customer_location_id":"int | null. Branch the invoice number sequence belongs to (= from_location_id).","[].info_name":"string | null. Requester name (dashboard fallback for \"Requested by\").","[].info_email":"string | null. Requester email.","[].status":"string. Pending | Draft PO Created | Declined (dashboard statuses); legacy rows: Requested, Approved, In Processing, Shipped, Delivered, Completed, Cancelled, …","[].user":"string | null. Creator username — dashboard \"Requested by\".","[].table_id":"int | null. Legacy (0). Not needed.","[].purchase_uuid":"string | null. Draft PO created from this request (get_purchase_order).","[].po_number":"string | null. That PO's number, e.g. \"PO-55\".","[].from_location":"string | null. Requesting branch name.","[].to_location":"string | null. Destination branch name.","[].location_name":"string | null. Same as from_location — dashboard \"Location\" column.","[].items":"array. Current lines (is_edit = 0), joined to materials.","[].items[].item_id":"int. Line row id.","[].items[].order_uuid":"string. = request uuid.","[].items[].material_id":"int. Material id (get_material).","[].items[].name":"string. Line name snapshot (fallback material name).","[].items[].price":"number | null. Unit cost (supplier price per storage unit).","[].items[].qty":"number. Requested quantity in the storage unit.","[].items[].subtotal":"number | null. qty × price.","[].items[].unit_name":"string | null. Unit label sent at creation (storage unit).","[].items[].supplier_id":"int | null. Supplier chosen for this line (list_suppliers); the Draft PO groups lines by it.","[].items[].is_edit":"string. \"0\". Legacy. Not needed.","[].items[].reason":"string | null. Legacy adjustment reason (wastage | return). Not needed.","[].items[].old_qty":"number | null. Qty before the last edit (legacy).","[].items[].new_qty":"number | null. Qty after the last edit (legacy).","[].items[].material_table_id":"int. materials.id (= material_id).","[].items[].material_name":"string. Current material name.","[].items[].material_unit":"string | null. Material storage unit.","[].items[].ingredientunit":"string | null. Material recipe unit (fallback storage unit) — for info only; qty is NOT in this unit.","[].items[].storageunit":"string | null. Material storage unit (qty unit).","[].items[].id":"int. = item_id (added for edit flows).","[].items[].material":"string. = name (fallback material_name).","[].current_location_id":"int. Branch of the calling user (or from_location_id). Not needed.","[].tax":"string. VAT % configured for the CALLER's branch, e.g. \"5.00\" (not necessarily the request branch)."},"example_request":{"uuid":"59c4fb6e-fb85-4ab1-ac0b-4338bd97b70a"},"example":[{"id":13,"username":"Nexus Cafe","subtotal":6000,"vat":300,"email":null,"delivery_date":"2026-08-06T12:52:39.000Z","payable":6300,"change":0,"note":"","uuid":"59c4fb6e-fb85-4ab1-ac0b-4338bd97b70a","reject_reason":null,"created_at":"2026-08-06T00:00:00.000Z","customer_id":437,"to_location_id":907,"from_location_id":907,"customer_location_id":907,"info_name":"Nexus Cafe","info_email":"admin@devnexus.co","status":"Pending","user":"Nexus Cafe","table_id":0,"purchase_uuid":null,"po_number":null,"from_location":"Default location","to_location":"Default location","location_name":"Default location","items":[{"item_id":35046,"order_uuid":"59c4fb6e-fb85-4ab1-ac0b-4338bd97b70a","material_id":112157,"name":"\t SRC-BT-86CBPR-SG3 BB86-BB92 INTERLOCK-W:86.5/89.5/92XOD:41 SHIMANO-CERAMIC COATED BLACK 4712910901471","price":3,"qty":2000,"subtotal":6000,"unit_name":"Pieces","supplier_id":881,"is_edit":"0","reason":null,"old_qty":null,"new_qty":null,"material_table_id":112157,"material_name":"\t SRC-BT-86CBPR-SG3 BB86-BB92 INTERLOCK-W:86.5/89.5/92XOD:41 SHIMANO-CERAMIC COATED BLACK 4712910901471","material_unit":"Pieces","ingredientunit":"Pieces","storageunit":"Pieces","id":35046,"material":"\t SRC-BT-86CBPR-SG3 BB86-BB92 INTERLOCK-W:86.5/89.5/92XOD:41 SHIMANO-CERAMIC COATED BLACK 4712910901471"}],"current_location_id":907,"tax":"5.00"}],"master_rules":["The dashboard request page (/order-request/<uuid>) shows REQ-<id>, status pill, location_name, requester, delivery_date, created_at, the lines (material, unit_name/storageunit, qty, price, subtotal, supplier) with totals, and — when status is Draft PO Created or purchase_uuid is set — the linked draft PO(s). Buttons: Edit (Pending + edit permission), Decline and Create Draft PO (Pending only), PDF.","Order request lifecycle (dashboard Inventory → Order Requests): a branch creates a request (status \"Pending\") listing materials with qty (storage unit), cost and a SUPPLIER per line. While Pending it can be edited, Declined (status → \"Declined\") or converted with \"Create Draft PO\" (one draft purchase order per supplier; status → \"Draft PO Created\", purchase_uuid / po_number filled). Old rows may show legacy statuses (Requested, Approved, In Processing, Delivered, Completed, Cancelled…). The MCP can create and edit Pending requests only; declining and Create Draft PO are dashboard actions.","To change lines use update_stock_order_request with ALL lines (copy material_id, name, qty, price, subtotal, unit_name, supplier_id from items[]) — only while status is Pending.","tax is the caller branch's VAT %, vat is the stored amount; do not mix them."]},"parameters":{"type":"object","properties":{"uuid":{"type":"string","description":"Order request uuid (list_stock_order_requests [].uuid)."}},"required":["uuid"]},"module":"stock_order_requests","safety":"read"},{"name":"create_stock_order_request","description":"POST /api/stock-order-request. Why: Create a branch order request (status Pending): a list of materials with quantity, cost and supplier that the branch wants purchased — the dashboard \"Create Order Request\" form. Nothing is ordered and no stock moves; in the dashboard a manager Declines it or clicks Create Draft PO (one draft PO per supplier). Materials from list_materials, suppliers from list_suppliers / list_supplier_materials, branch from list_locations. To create a purchase order directly use create_draft_purchase_order; to move stock between branches use create_stock_transfer. Response echoes the body (no id); find it with list_stock_order_requests (st=Pending) by uuid. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_stock_order_request\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/stock-order-request","why":"Create a branch order request (status Pending): a list of materials with quantity, cost and supplier that the branch wants purchased — the dashboard \"Create Order Request\" form. Nothing is ordered and no stock moves; in the dashboard a manager Declines it or clicks Create Draft PO (one draft PO per supplier). Materials from list_materials, suppliers from list_suppliers / list_supplier_materials, branch from list_locations. To create a purchase order directly use create_draft_purchase_order; to move stock between branches use create_stock_transfer.","response":{"description":"200 → the request body echoed back (JSON text) with customer_id (tenant) and location_id (= from_location_id) added; no id / request number. The server sets username/user/user_id = API key user, invoice_id = next number for that branch, change = 0, created_at = now. There is no error handling: a DB error or missing items leaves the call without a response (timeout) and possibly a header without lines.","fields":{"uuid":"string. Echo of your uuid — use it with get_stock_order_request.","from_location_id":"int. Echo.","to_location_id":"int. Echo.","items":"array. Echo of your lines.","items[].material_id":"int. Echo.","items[].name":"string. Echo.","items[].qty":"number. Echo.","items[].price":"number. Echo.","items[].subtotal":"number. Echo.","items[].unit_name":"string. Echo.","items[].supplier_id":"int. Echo.","subtotal":"number. Echo.","vat":"number. Echo.","payable":"number. Echo.","note":"string. Echo.","delivery_date":"string. Echo.","info_name":"string. Echo.","info_email":"string. Echo.","table_id":"int. Echo.","status":"string. \"Pending\" (forced).","customer_id":"int. Tenant id added by the server.","location_id":"int. = from_location_id (invoice-number branch)."},"example_request":{"uuid":"b8a4c7f2-5d61-4f3e-9c0a-1e2d3f4a5b6c","from_location_id":907,"to_location_id":907,"subtotal":6000,"vat":300,"payable":6300,"note":"","info_name":"Nexus Cafe","delivery_date":"2026-09-30 09:00:00","table_id":0,"items":[{"material_id":112157,"name":"SRC-BT-86CBPR-SG3","qty":2000,"price":3,"subtotal":6000,"unit_name":"Pieces","supplier_id":881}]},"example":{"uuid":"b8a4c7f2-5d61-4f3e-9c0a-1e2d3f4a5b6c","from_location_id":907,"to_location_id":907,"subtotal":6000,"vat":300,"payable":6300,"note":"","info_name":"Nexus Cafe","delivery_date":"2026-09-30 09:00:00","table_id":0,"items":[{"material_id":112157,"name":"SRC-BT-86CBPR-SG3","qty":2000,"price":3,"subtotal":6000,"unit_name":"Pieces","supplier_id":881}],"status":"Pending","customer_id":437,"location_id":907},"master_rules":["Dashboard form (Order Requests → Create): Location (defaults to the user's branch), Delivery date (default now), Include VAT toggle (default on, VAT % from branch config), materials added from a search dialog (qty > 0 in storage unit, unit = storageunit); each line gets a supplier (default = the material's preferred/cheapest supplier, price = that supplier's price, else avg cost) — Submit is disabled until every line has a supplier. It posts { uuid, subtotal, vat (= subtotal × VAT% or 0), payable, note \"\", info_name, info_email, from_location_id = to_location_id = location, status \"Pending\", table_id 0, delivery_date \"YYYY-MM-DD HH:mm:ss\", items: [{ name, price, qty, subtotal, material_id, unit_name, supplier_id }] } (also location_id, payment_method \"\", include_vat, which the server ignores). Build the same body.","Round money to the tenant's decimals and compute subtotal/vat/payable yourself — the server stores them as sent.","Order request lifecycle (dashboard Inventory → Order Requests): a branch creates a request (status \"Pending\") listing materials with qty (storage unit), cost and a SUPPLIER per line. While Pending it can be edited, Declined (status → \"Declined\") or converted with \"Create Draft PO\" (one draft purchase order per supplier; status → \"Draft PO Created\", purchase_uuid / po_number filled). Old rows may show legacy statuses (Requested, Approved, In Processing, Delivered, Completed, Cancelled…). The MCP can create and edit Pending requests only; declining and Create Draft PO are dashboard actions.","After creating, tell the user the request REQ-<n> is Pending and that a manager declines it or creates the draft PO(s) in the dashboard; nothing is ordered yet and no stock moves. If the user wants the PO immediately, use create_draft_purchase_order instead of (not in addition to) an order request."]},"parameters":{"type":"object","properties":{"uuid":{"type":"string","description":"New random UUID v4 you generate (dashboard uuidv4())."},"from_location_id":{"type":"number","description":"Requesting branch id (list_locations)."},"to_location_id":{"type":"number","description":"Destination branch — the dashboard sends the SAME id as from_location_id (the branch receives what it requested)."},"items":{"type":"array","items":{"type":"object","properties":{"material_id":{"type":"number","description":"Material id (list_materials)."},"name":{"type":"string","description":"Material name snapshot (dashboard sends the material name)."},"qty":{"type":"number","description":"Quantity in the material's STORAGE unit, > 0 (the draft PO uses the same qty; no conversion)."},"price":{"type":"number","description":"Unit cost per storage unit: the chosen supplier's price for the material (dashboard default), else material avg cost/purchase price."},"subtotal":{"type":"number","description":"qty × price (stored as sent)."},"unit_name":{"type":"string","description":"Storage-unit label, e.g."},"supplier_id":{"type":"number","description":"Supplier for this line (list_suppliers / list_supplier_materials)."}},"required":["material_id","name","qty","price","subtotal","supplier_id"],"description":""},"description":"Requested lines (at least one)."},"subtotal":{"type":"number","description":"Σ line subtotals (stored as sent; not recomputed)."},"vat":{"type":"number","description":"VAT AMOUNT = subtotal × branch VAT % (dashboard \"Include VAT\" default on), or 0."},"payable":{"type":"number","description":"subtotal + vat (stored as sent)."},"note":{"type":"string","description":"Header note (max 300)."},"delivery_date":{"type":"string","description":"Requested delivery date-time \"YYYY-MM-DD HH:mm:ss\" (dashboard date picker, default now)."},"info_name":{"type":"string","description":"Requester name (dashboard: logged-in username)."},"info_email":{"type":"string","description":"Requester email."},"table_id":{"type":"number","description":"Legacy; dashboard sends 0."}},"required":["uuid","from_location_id","to_location_id","items"]},"module":"stock_order_requests","safety":"write"},{"name":"update_stock_order_request","description":"PUT /api/stock-order-request/update/:uuid. Why: Edit a PENDING order request — lines (material, qty, price, supplier), totals, note, delivery date — like the dashboard Edit page / Draft PO page \"Save changes\". Read it first with get_stock_order_request; the line list is replaced entirely. MCP strips status / reason / reject_reason, so the request stays Pending; declining or creating the draft PO stays in the dashboard. No stock moves. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_stock_order_request\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/stock-order-request/update/:uuid","why":"Edit a PENDING order request — lines (material, qty, price, supplier), totals, note, delivery date — like the dashboard Edit page / Draft PO page \"Save changes\". Read it first with get_stock_order_request; the line list is replaced entirely. MCP strips status / reason / reject_reason, so the request stays Pending; declining or creating the draft PO stays in the dashboard. No stock moves.","response":{"description":"200 → { message: \"Order updated successfully\", data: { uuid, subtotal, payable, vat, status, items } } (items = the lines you sent). Also sets last_updated_by and is_edit on the header. Errors: 400 { message: \"Invalid payload\" } (items not an array or uuid missing), 404 { message: \"Order not found\" }, 500 { message: \"Internal server error\", error }. The server does not check the status — only call it on Pending requests.","fields":{"message":"string. \"Order updated successfully\" (or the error text).","data":"object. Summary of what was saved.","data.uuid":"string. Request uuid.","data.subtotal":"number. Saved subtotal.","data.payable":"number. Saved payable.","data.vat":"number. Saved VAT amount.","data.status":"string. Current status (unchanged, e.g. \"Pending\").","data.items":"array. Echo of the lines you sent.","data.items[].material_id":"int. Echo.","data.items[].name":"string. Echo.","data.items[].qty":"number. Echo.","data.items[].price":"number. Echo.","data.items[].subtotal":"number. Echo.","data.items[].unit_name":"string. Echo.","data.items[].supplier_id":"int. Echo.","error":"object. Only on 500: the raw error."},"example_request":{"uuid":"59c4fb6e-fb85-4ab1-ac0b-4338bd97b70a","subtotal":4500,"vat":225,"payable":4725,"items":[{"material_id":112157,"name":"SRC-BT-86CBPR-SG3","qty":1500,"price":3,"subtotal":4500,"unit_name":"Pieces","supplier_id":881}]},"example":{"message":"Order updated successfully","data":{"uuid":"59c4fb6e-fb85-4ab1-ac0b-4338bd97b70a","subtotal":4500,"payable":4725,"vat":225,"status":"Pending","items":[{"material_id":112157,"name":"SRC-BT-86CBPR-SG3","qty":1500,"price":3,"subtotal":4500,"unit_name":"Pieces","supplier_id":881}]}},"master_rules":["Dashboard callers: the Edit page (Pending + edit permission) sends { uuid, subtotal, vat, payable, note, info_name, info_email, from_location_id = to_location_id = location, table_id, delivery_date, status \"Pending\", items: [{ name, price, qty, subtotal, material_id, unit_name, supplier_id }] }; the Create Draft PO page \"Save changes\" sends the same with to_location_id = chosen PO destination and the request's current status. Every line must have a supplier.","MCP use: get_stock_order_request first; only if status is Pending. Copy every line, change what the user asked, recompute subtotal/vat/payable, send all lines. Never send items[].reason.","Order request lifecycle (dashboard Inventory → Order Requests): a branch creates a request (status \"Pending\") listing materials with qty (storage unit), cost and a SUPPLIER per line. While Pending it can be edited, Declined (status → \"Declined\") or converted with \"Create Draft PO\" (one draft purchase order per supplier; status → \"Draft PO Created\", purchase_uuid / po_number filled). Old rows may show legacy statuses (Requested, Approved, In Processing, Delivered, Completed, Cancelled…). The MCP can create and edit Pending requests only; declining and Create Draft PO are dashboard actions.","No stock moves and nothing is ordered; after the edit the dashboard user can Create Draft PO (or use create_draft_purchase_order yourself if the user wants the PO now)."]},"parameters":{"type":"object","properties":{"uuid":{"type":"string","description":"Order request uuid (list_stock_order_requests)."},"items":{"type":"array","items":{"type":"object","properties":{"material_id":{"type":"number","description":"Material id (list_materials)."},"name":{"type":"string","description":"Material name snapshot (dashboard sends the material name)."},"qty":{"type":"number","description":"Quantity in the material's STORAGE unit, > 0 (the draft PO uses the same qty; no conversion)."},"price":{"type":"number","description":"Unit cost per storage unit: the chosen supplier's price for the material (dashboard default), else material avg cost/purchase price."},"subtotal":{"type":"number","description":"qty × price (stored as sent)."},"unit_name":{"type":"string","description":"Storage-unit label, e.g."},"supplier_id":{"type":"number","description":"Supplier for this line (list_suppliers / list_supplier_materials)."},"old_qty":{"type":"number","description":"Audit: qty before the edit."},"new_qty":{"type":"number","description":"Audit: qty after the edit."}},"required":["material_id","name","qty","price","subtotal","supplier_id"],"description":""},"description":"The COMPLETE new line list — all existing lines are deleted and these inserted (a line you omit is removed; a field you omit becomes null)."},"subtotal":{"type":"number","description":"New Σ line subtotals."},"vat":{"type":"number","description":"New VAT AMOUNT (subtotal × branch VAT % or 0)."},"payable":{"type":"number","description":"New subtotal + vat."},"note":{"type":"string","description":"Header note."},"delivery_date":{"type":"string","description":"\"YYYY-MM-DD HH:mm:ss\"; null/\"\" clears; omitted → unchanged."},"from_location_id":{"type":"number","description":"Requesting branch (list_locations)."},"to_location_id":{"type":"number","description":"Destination branch (dashboard: same as from_location_id, or the PO destination on the Draft PO page)."},"info_name":{"type":"string","description":"Requester name."},"info_email":{"type":"string","description":"Requester email."},"table_id":{"type":"number","description":"Legacy (dashboard sends the existing value or 0)."}},"required":["uuid","items"]},"module":"stock_order_requests","safety":"write"},{"name":"list_work_orders","description":"GET /api/work-orders. Why: Production work orders (batches of stockable recipes to produce at a branch) with status, dates, branch, cost totals and a packed summary of their recipe lines. Filter by status, production date range, branch, recipe or text; get ids for get_work_order (lines) → update_work_order / delete_work_order (drafts only). Completed orders are the ones that moved stock (list_inventory_movements, reference WORK_ORDER_<uuid>). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_work_orders\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/work-orders","why":"Production work orders (batches of stockable recipes to produce at a branch) with status, dates, branch, cost totals and a packed summary of their recipe lines. Filter by status, production date range, branch, recipe or text; get ids for get_work_order (lines) → update_work_order / delete_work_order (drafts only). Completed orders are the ones that moved stock (list_inventory_movements, reference WORK_ORDER_<uuid>).","response":{"description":"200 → { success, message, data: { data: workOrder[], pagination } } — the list is at data.data. ORDER BY id DESC. qr_png is removed. 500 → { success: false, message: \"Failed to get all work orders\", error }.","fields":{"success":"boolean. true.","message":"string. \"Work orders fetched successfully\".","data":"object. Wrapper.","data.data":"array. Work orders on this page.","data.data[].id":"int. Work order id. Pass as `id` to get_work_order / update_work_order / delete_work_order.","data.data[].uuid":"string. Upper-case UUID. Stock movements of a completed order carry reference \"WORK_ORDER_<uuid>\" (list_inventory_movements).","data.data[].public_slug":"string. Slug of the public (no-login) work-order page /public/work-orders/<slug> that the QR code opens.","data.data[].work_order_no":"string. Number \"PRD-<year of production_date>-<5-digit counter>\", assigned on create.","data.data[].status":"\"draft\" | \"in_progress\" | \"completed\" | \"cancelled\". draft = editable/deletable (MCP creates only drafts); in_progress = started in the dashboard (no stock yet); completed = stock posted (materials consumed, prepared item produced); cancelled = cancelled in the dashboard. Only draft can be changed via MCP.","data.data[].production_date":"string (ISO timestamp, UTC midnight of the stored DATE, e.g. \"2026-08-05T00:00:00.000Z\"). Planned production day — take the date part.","data.data[].expected_completion":"string (ISO timestamp) | null. Expected completion day (date part).","data.data[].created_by":"int. User id of the creator.","data.data[].notes":"string | null. Free-text note (the dashboard neither shows an input nor sends it; its Edit clears it).","data.data[].net_total":"number. Planned production cost. In list_work_orders it is recomputed as Σ recipes total_cost (falls back to the stored value when there are no recipe lines); in get_work_order it is the stored value (create: as sent; update: Σ recipes[].total_cost).","data.data[].location_id":"int. Branch (list_locations) where materials are consumed and the prepared item is produced.","data.data[].customer_id":"int. Tenant id. Not needed.","data.data[].created_at":"string (ISO timestamp). Creation time.","data.data[].updated_at":"string (ISO timestamp). Last change (edit or status change).","data.data[].total_materials_cost":"number | null. Σ materials total_price of the order (null when it has no material lines).","data.data[].total_recipes_cost":"number | null. Σ recipes total_cost.","data.data[].recipes_count":"int. Number of recipe lines.","data.data[].materials_count":"int. Number of material (consumption) lines. 0 → completing the order consumes nothing.","data.data[].recipe_data":"string | null. Recipe lines packed as \"<recipe_name>|<qty>|<unit_cost>|<total_cost>\" joined by \"||\" (in line order); null when any line has no recipe_name. The dashboard splits it for the Recipe and Quantity columns.","data.data[].location_name":"string | null. Branch name of location_id.","data.data[].item_brand_names_csv":"string | null. Distinct brand names of the material lines, joined by \"|||\" (null = no brands).","data.pagination":"object. Paging info.","data.pagination.total":"int. Orders matching the filters.","data.pagination.page":"int. Page returned.","data.pagination.limit":"int. Page size."},"master_rules":["Dashboard Inventory → Work Orders (\"Production\") sends _page, _limit, status (All/Draft/In Progress/Completed), from, to (production date), loc_id (defaults to the user's branch; \"All\" = omitted), k (search) and cat (ignored). Tabs: history table (work_order_no, recipes + quantities from recipe_data, net_total, location, created_at), cost-control view and chef view (production_date, status: draft shown as \"Ready to Start\"). Stat cards count statuses on the current page only.","Lifecycle as the dashboard runs it: draft (MCP create) → \"Move to In Progress\" (PUT /work-orders/:id/status) → \"Complete Production\" (POST /work-orders/:id/confirm) → completed: consumes every material line at location_id (CONSUMED movements) and adds each recipe line's qty to the recipe's prepared material (PRODUCED movement at unit_cost). Non-draft orders can be cancelled (status cancelled). None of these steps is available via MCP — tell the user to do them in the dashboard.","To find stock effects of a completed order use list_inventory_movements (reference WORK_ORDER_<uuid>).","materials_count 0 or recipe_data null flag incomplete orders (no consumption lines / missing recipe_name)."],"example":{"success":true,"message":"Work orders fetched successfully","data":{"data":[{"id":1737,"uuid":"66663B01-F720-4574-A3CF-65F6F1F02BB3","public_slug":"8d090ad8-258f-41cd-91ae-ebab74853540","work_order_no":"PRD-2026-01659","status":"completed","production_date":"2026-08-05T00:00:00.000Z","expected_completion":null,"created_by":4734,"notes":null,"net_total":23017.9029,"location_id":907,"customer_id":437,"created_at":"2026-08-05T07:03:22.000Z","updated_at":"2026-08-05T07:03:58.000Z","total_materials_cost":23017.9029,"total_recipes_cost":23017.9029,"recipes_count":1,"materials_count":2,"recipe_data":"Gotcha Recipe|10000.000000|2.3018|23017.9029","location_name":"Default location","item_brand_names_csv":null}],"pagination":{"total":29,"page":1,"limit":1}}}},"parameters":{"type":"object","properties":{"_page":{"type":"number","description":"1-based page."},"_limit":{"type":"number","description":"Page size."},"status":{"type":"string","enum":["draft","in_progress","completed","cancelled"],"description":"Exact status."},"from":{"type":"string","description":"production_date ≥ this day (YYYY-MM-DD)."},"to":{"type":"string","description":"production_date ≤ this day (YYYY-MM-DD, inclusive)."},"loc_id":{"type":"number","description":"ONE branch id (list_locations): orders of that branch."},"recipe_id":{"type":"number","description":"Only orders that contain this recipe (list_recipes)."},"k":{"type":"string","description":"Substring match on work_order_no, id, notes or a line's recipe_name (`%`/`_` are wildcards)."}},"required":[]},"module":"work_orders","safety":"read"},{"name":"get_work_order","description":"GET /api/work-orders/:id. Why: One work order: header (status, dates, branch, notes, creator, QR image), its recipe lines (what is produced, qty in the recipe unit, cost) and material lines (what completion will consume, yield-adjusted qty, price). Call it before update_work_order (full replace) and to explain what completing it will do to stock. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_work_order\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/work-orders/:id","why":"One work order: header (status, dates, branch, notes, creator, QR image), its recipe lines (what is produced, qty in the recipe unit, cost) and material lines (what completion will consume, yield-adjusted qty, price). Call it before update_work_order (full replace) and to explain what completing it will do to stock.","response":{"description":"200 → { success, message, data: [ { header, materials, recipes } ] } — a ONE-ELEMENT array. Unknown id / another tenant's → 500 { success: false, message: \"Failed to get work order\", error: \"Work order not found\" } (not 404).","fields":{"success":"boolean. true.","message":"string. \"Work order fetched successfully\" (or the failure text).","error":"string. \"Work order not found\" on the 500 for an unknown id.","data":"array. Exactly one element.","data[0].header":"object. The work_orders row + names.","data[0].header.id":"int. Work order id. Pass as `id` to get_work_order / update_work_order / delete_work_order.","data[0].header.uuid":"string. Upper-case UUID. Stock movements of a completed order carry reference \"WORK_ORDER_<uuid>\" (list_inventory_movements).","data[0].header.public_slug":"string. Slug of the public (no-login) work-order page /public/work-orders/<slug> that the QR code opens.","data[0].header.work_order_no":"string. Number \"PRD-<year of production_date>-<5-digit counter>\", assigned on create.","data[0].header.status":"\"draft\" | \"in_progress\" | \"completed\" | \"cancelled\". draft = editable/deletable (MCP creates only drafts); in_progress = started in the dashboard (no stock yet); completed = stock posted (materials consumed, prepared item produced); cancelled = cancelled in the dashboard. Only draft can be changed via MCP.","data[0].header.production_date":"string (ISO timestamp, UTC midnight of the stored DATE, e.g. \"2026-08-05T00:00:00.000Z\"). Planned production day — take the date part.","data[0].header.expected_completion":"string (ISO timestamp) | null. Expected completion day (date part).","data[0].header.created_by":"int. User id of the creator.","data[0].header.notes":"string | null. Free-text note (the dashboard neither shows an input nor sends it; its Edit clears it).","data[0].header.net_total":"number. Planned production cost. In list_work_orders it is recomputed as Σ recipes total_cost (falls back to the stored value when there are no recipe lines); in get_work_order it is the stored value (create: as sent; update: Σ recipes[].total_cost).","data[0].header.location_id":"int. Branch (list_locations) where materials are consumed and the prepared item is produced.","data[0].header.customer_id":"int. Tenant id. Not needed.","data[0].header.created_at":"string (ISO timestamp). Creation time.","data[0].header.updated_at":"string (ISO timestamp). Last change (edit or status change).","data[0].header.qr_png":"object | null. QR PNG as a serialised Buffer { type: \"Buffer\", data: [byte, …] } (thousands of numbers) once a QR was generated in the dashboard; null before. Ignore it — the QR only encodes the public page /public/work-orders/<public_slug>.","data[0].header.location_name":"string | null. Branch name.","data[0].header.created_by_name":"string | null. Username of the creator.","data[0].materials":"array. Consumption lines (work_order_materials), in insert order. Completion deducts exactly these.","data[0].materials[].id":"int. Line id (lines are re-created on every update).","data[0].materials[].work_order_id":"int. This order.","data[0].materials[].recipe_id":"int | null. Recipe line it belongs to.","data[0].materials[].material_id":"int. Material consumed (get_material).","data[0].materials[].unit":"string. Ingredient unit of qty.","data[0].materials[].qty":"number. Quantity consumed at completion (already yield-adjusted).","data[0].materials[].unit_price":"number. Cost per ingredient unit used as the consumption price (4 decimals).","data[0].materials[].total_price":"number. qty × unit_price.","data[0].materials[].customer_id":"int. Tenant id. Not needed.","data[0].materials[].created_at":"string (ISO timestamp). Line creation time.","data[0].materials[].material_name":"string | null. Name snapshot (null when not sent).","data[0].materials[].brand_id":"int | null. Brand of the line.","data[0].materials[].brand_name":"string | null. Brand name.","data[0].recipes":"array. Production lines (work_order_recipes).","data[0].recipes[].id":"int. Line id.","data[0].recipes[].work_order_id":"int. This order.","data[0].recipes[].recipe_id":"int. Recipe produced (get_recipe).","data[0].recipes[].qty":"number. Output quantity in the recipe's unit; completion adds it to the recipe's prepared material.","data[0].recipes[].unit_cost":"number. Cost per output unit (price of the PRODUCED movement).","data[0].recipes[].total_cost":"number. Planned cost of the line.","data[0].recipes[].customer_id":"int. Tenant id. Not needed.","data[0].recipes[].created_at":"string (ISO timestamp). Line creation time.","data[0].recipes[].recipe_name":"string | null. Name snapshot."},"master_rules":["Dashboard detail page (Work Orders → row): status chip (Draft / In Progress / Completed), Total Cost = header.net_total, Location, Production Date, Expected Completion, Created By, Notes (\"No notes\"), recipe lines and material lines grouped by recipe, QR code. Draft + permission → Edit (opens the create form pre-filled from these lines) and \"Delete Order\"; in_progress → \"Complete Production\" and \"Cancel Order\"; completed/cancelled → no actions.","Explain stock impact from the lines: completing consumes materials[].qty of each material at header.location_id and produces recipes[].qty of each recipe's prepared material. Completion happens only in the dashboard, and only after \"Move to In Progress\".","To edit a draft: resend header dates/location/notes and every recipe line with its materials (update_work_order is a full replace)."],"example":{"success":true,"message":"Work order fetched successfully","data":[{"header":{"id":1737,"uuid":"66663B01-F720-4574-A3CF-65F6F1F02BB3","public_slug":"8d090ad8-258f-41cd-91ae-ebab74853540","work_order_no":"PRD-2026-01659","status":"completed","production_date":"2026-08-05T00:00:00.000Z","expected_completion":null,"created_by":4734,"notes":null,"net_total":23017.9029,"location_id":907,"customer_id":437,"qr_png":{"type":"Buffer","data":[137,80,78,71,"…3870 byte values…"]},"created_at":"2026-08-05T07:03:22.000Z","updated_at":"2026-08-05T07:03:58.000Z","location_name":"Default location","created_by_name":"Nexus Cafe"},"materials":[{"id":23605,"work_order_id":1737,"recipe_id":17224,"material_id":119196,"unit":"Gram","qty":21312872.975277,"unit_price":0.0001,"total_price":2557.5448,"customer_id":437,"created_at":"2026-08-05T07:03:22.000Z","material_name":"Test Material","brand_id":null,"brand_name":null},{"id":23606,"work_order_id":1737,"recipe_id":17224,"material_id":119158,"unit":"Gram","qty":12787723.785166,"unit_price":0.0016,"total_price":20460.3581,"customer_id":437,"created_at":"2026-08-05T07:03:22.000Z","material_name":"Cherry Blosom","brand_id":null,"brand_name":null}],"recipes":[{"id":4470,"work_order_id":1737,"recipe_id":17224,"qty":10000,"unit_cost":2.3018,"total_cost":23017.9029,"customer_id":437,"created_at":"2026-08-05T07:03:22.000Z","recipe_name":"Gotcha Recipe"}]}]}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Work order id (list_work_orders data.data[].id)."}},"required":["id"]},"module":"work_orders","safety":"read"},{"name":"create_work_order","description":"POST /api/work-orders. Why: Plan a production batch of STOCKABLE recipes at one branch, as a DRAFT (MCP forces status \"draft\"): what to produce (recipe, output qty) and what it will consume (one material line per ingredient, computed from get_recipe). No stock moves now: the user moves it to In Progress and completes it in the dashboard, which then consumes the material lines and adds the output to the recipes' prepared materials (list_inventory_movements). Each recipes[] line must carry materials[] (one per recipe ingredient, yield-adjusted qty, unit, unit_price, total_price) — the server never derives them, and completion consumes only these lines. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_work_order\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/work-orders","why":"Plan a production batch of STOCKABLE recipes at one branch, as a DRAFT (MCP forces status \"draft\"): what to produce (recipe, output qty) and what it will consume (one material line per ingredient, computed from get_recipe). No stock moves now: the user moves it to In Progress and completes it in the dashboard, which then consumes the material lines and adds the output to the recipes' prepared materials (list_inventory_movements).","response":{"description":"201 → { success: true, message: \"Work order created successfully\", data: { id, work_order_no } }. 500 → { success: false, error } (note: `error`, no `message`) — e.g. \"No recipes provided\", or a DB error for a missing production_date / location_id / line unit. Transactional: nothing is saved on error. The server stores lines exactly as sent (qty rounded to 6, prices to 4 decimals) and validates nothing else (recipe mode, material ids, maths).","fields":{"success":"boolean. true on 201; false on error.","message":"string. \"Work order created successfully\" (201 only).","data":"object. The new order.","data.id":"int. Work order id → get_work_order.","data.work_order_no":"string. \"PRD-<year>-<nnnnn>\".","error":"string. Error text (500 only)."},"master_rules":["Dashboard \"New Production\" modal (Work Orders → Create): Location (defaults to the user's branch; required), recipe picker = list_recipes mode=stockable limit=500 (optional brand filter), per recipe an output quantity (default = the recipe's yield_qty, i.e. one batch; > 0, ≤ 6 decimals), Production Date (default today), Expected Completion (optional). Buttons \"Save as Draft\" and \"Start Production Now\" (status in_progress) — MCP can only save drafts.","Build each recipes[] line like the dashboard: get_recipe(recipe_id) → factor = qty / yield_qty; for every recipe_items line: effective_yield = yield_pct ?? material yield_percentage ?? 100 (get_material); scaled = line qty × factor; materials[].qty = scaled / (effective_yield / 100) (6 decimals); unit_price = material avg_price / conversionrate (list_materials loc=<location_id>, _limit large); total_price = scaled × unit_price / (effective_yield / 100) (4 decimals); unit = the material's ingredientunit; material_name = its name. Then total_cost = Σ total_price, unit_cost = total_cost / qty, and net_total = Σ total_cost.","Never omit materials[]: completion only consumes the stored material lines, so an order without them produces the prepared item for free and leaves ingredient stock untouched.","Only stockable recipes: completing a phantom recipe's order would add stock to a prepared material that sales never deduct. Check the branch has enough of each material (get_material_stock / list_materials loc) and warn the user about shortfalls — completion does not block on negative stock.","The dashboard does not send notes; you may (shown on the detail page). recipes[].unit / unit_id and materials[].unit_id that the dashboard sends are ignored by the server.","After creating: get_work_order(data.id) to confirm, then tell the user to open Inventory → Work Orders → the order and use \"Move to In Progress\" then \"Complete Production\" (the agent cannot start, complete or cancel an order)."],"example_request":{"production_date":"2026-09-27","expected_completion":"2026-09-28","location_id":907,"net_total":22.3566,"recipes":[{"recipe_id":16779,"recipe_name":"Test","qty":1.6,"unit_cost":13.9729,"total_cost":22.3566,"materials":[{"material_id":119196,"material_name":"Test Material","unit":"Gram","qty":1000,"unit_price":0.022,"total_price":22.0271,"brand_id":null},{"material_id":119158,"material_name":"Cherry Blosom","unit":"Gram","qty":1000,"unit_price":0.0003,"total_price":0.3295,"brand_id":null}]}]},"example":{"success":true,"message":"Work order created successfully","data":{"id":1738,"work_order_no":"PRD-2026-01660"}}},"parameters":{"type":"object","properties":{"production_date":{"type":"string","description":"Planned production day YYYY-MM-DD (NOT NULL)."},"expected_completion":{"type":"string","description":"Expected completion day YYYY-MM-DD; omitted/null → none."},"location_id":{"type":"number","description":"ONE branch id (list_locations) — where materials are consumed and the item produced (NOT NULL; completion refuses an order without it)."},"notes":{"type":"string","description":"Free-text note."},"net_total":{"type":"number","description":"Planned cost = Σ recipes[].total_cost (stored as sent; 0/omitted → null)."},"recipes":{"type":"array","items":{"type":"object","properties":{"recipe_id":{"type":"number","description":"STOCKABLE recipe id (list_recipes mode=stockable)."},"recipe_name":{"type":"string","description":"Recipe name snapshot."},"qty":{"type":"number","description":"OUTPUT quantity to produce, in the recipe's `unit` (not batches)."},"unit_cost":{"type":"number","description":"total_cost / qty (4 decimals)."},"total_cost":{"type":"number","description":"Σ materials[].total_price of this line (4 decimals)."},"materials":{"type":"array","items":{"type":"object","properties":{"material_id":{"type":"number","description":"Ingredient material id = the recipe line's material_id (get_recipe.recipe_items[])."},"material_name":{"type":"string","description":"Material name snapshot (get_recipe.recipe_items[].name)."},"unit":{"type":"string","description":"The material's ingredientunit."},"qty":{"type":"number","description":"Stock to CONSUME at completion, yield-adjusted: recipe line qty × (recipes[].qty / recipe yield_qty) / (effective_yield / 100), where effective_yield = line yi…"},"unit_price":{"type":"number","description":"Cost per ingredient unit = material avg_price / conversionrate (list_materials with loc = the work-order branch)."},"total_price":{"type":"number","description":"qty × unit_price (4 decimals)."},"brand_id":{"type":"number","description":"Brand id (list_brands) for this consumption line; null/omitted = none (the dashboard keeps a brand only when an existing line had one)."}},"required":["material_id","unit","qty","unit_price","total_price"],"description":""},"description":"One consumption line per recipe ingredient, computed as below."}},"required":["recipe_id","recipe_name","qty","unit_cost","total_cost","materials"],"description":""},"description":"Recipes to produce, at least one (none → 500 \"No recipes provided\")."}},"required":["production_date","location_id","recipes"]},"module":"work_orders","safety":"write"},{"name":"update_work_order","description":"PUT /api/work-orders/:id. Why: Edit a DRAFT work order: dates, branch, notes and the full set of recipe + material lines (full replace; net_total is recomputed from the lines). MCP strips `status`. Non-draft orders are refused. No stock moves. Start from get_work_order. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_work_order\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/work-orders/:id","why":"Edit a DRAFT work order: dates, branch, notes and the full set of recipe + material lines (full replace; net_total is recomputed from the lines). MCP strips `status`. Non-draft orders are refused. No stock moves. Start from get_work_order.","response":{"description":"200 → { success: true, message: \"Work order updated successfully\", data: { production_date, expected_completion, notes, net_total, location_id } } (the header values written — no id, no lines). 500 → { success: false, message } for \"Work order not found\", \"Only draft work orders can be edited\" or a DB error (e.g. missing production_date, location_id, line unit_cost/total_cost, material unit/unit_price/total_price). Transactional: rolled back on error.","fields":{"success":"boolean. true on 200; false on error.","message":"string. \"Work order updated successfully\" or the error text.","data":"object. Header values written.","data.production_date":"string. As sent.","data.expected_completion":"string | null. As sent (null when omitted).","data.notes":"string | null. As sent (null when omitted).","data.net_total":"number. Σ recipes[].total_cost.","data.location_id":"number. As sent."},"master_rules":["Dashboard: Edit is offered only on draft orders (detail page); it reopens the New Production form pre-filled from get_work_order (recipes with qty; material lines are recomputed from the CURRENT recipe and material prices, keeping line brand_ids) and sends { production_date, expected_completion, location_id, net_total, recipes[...] } — without notes, so a dashboard edit clears notes.","Full replace: omitted recipes → all lines deleted; omitted notes / expected_completion → cleared; production_date and location_id must always be sent. Build lines exactly as for create_work_order (materials[] per recipe line, yield-adjusted).","To change only the date or note, resend the existing lines from get_work_order: recipes[] = { recipe_id, recipe_name, qty, unit_cost, total_cost, materials: materials filtered by recipe_id → { material_id, material_name, unit, qty, unit_price, total_price, brand_id } }.","Status changes (start, complete, cancel) are dashboard-only; MCP removes `status` from the body."],"example_request":{"production_date":"2026-09-27","expected_completion":"2026-09-28","location_id":907,"recipes":[{"recipe_id":16779,"recipe_name":"Test","qty":1.6,"unit_cost":13.9729,"total_cost":22.3566,"materials":[{"material_id":119196,"material_name":"Test Material","unit":"Gram","qty":1000,"unit_price":0.022,"total_price":22.0271,"brand_id":null},{"material_id":119158,"material_name":"Cherry Blosom","unit":"Gram","qty":1000,"unit_price":0.0003,"total_price":0.3295,"brand_id":null}]}],"notes":"Morning batch"},"example":{"success":true,"message":"Work order updated successfully","data":{"production_date":"2026-09-27","expected_completion":"2026-09-28","notes":"Morning batch","net_total":22.3566,"location_id":907}}},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Work order id (list_work_orders); must be a draft (get_work_order header.status)."},"production_date":{"type":"string","description":"Planned production day YYYY-MM-DD (NOT NULL)."},"expected_completion":{"type":"string","description":"Expected completion day YYYY-MM-DD; omitted/null → none."},"location_id":{"type":"number","description":"ONE branch id (list_locations) — where materials are consumed and the item produced (NOT NULL; completion refuses an order without it)."},"notes":{"type":"string","description":"Free-text note."},"recipes":{"type":"array","items":{"type":"object","properties":{"recipe_id":{"type":"number","description":"STOCKABLE recipe id (list_recipes mode=stockable)."},"recipe_name":{"type":"string","description":"Recipe name snapshot."},"qty":{"type":"number","description":"OUTPUT quantity to produce, in the recipe's `unit` (not batches)."},"unit_cost":{"type":"number","description":"total_cost / qty (4 decimals)."},"total_cost":{"type":"number","description":"Σ materials[].total_price of this line (4 decimals)."},"materials":{"type":"array","items":{"type":"object","properties":{"material_id":{"type":"number","description":"Ingredient material id = the recipe line's material_id (get_recipe.recipe_items[])."},"material_name":{"type":"string","description":"Material name snapshot (get_recipe.recipe_items[].name)."},"unit":{"type":"string","description":"The material's ingredientunit."},"qty":{"type":"number","description":"Stock to CONSUME at completion, yield-adjusted: recipe line qty × (recipes[].qty / recipe yield_qty) / (effective_yield / 100), where effective_yield = line yi…"},"unit_price":{"type":"number","description":"Cost per ingredient unit = material avg_price / conversionrate (list_materials with loc = the work-order branch)."},"total_price":{"type":"number","description":"qty × unit_price (4 decimals)."},"brand_id":{"type":"number","description":"Brand id (list_brands) for this consumption line; null/omitted = none (the dashboard keeps a brand only when an existing line had one)."}},"required":["material_id","unit","qty","unit_price","total_price"],"description":""},"description":"One consumption line per recipe ingredient, computed as below."}},"required":["recipe_id","recipe_name","qty","unit_cost","total_cost","materials"],"description":""},"description":"FULL list of lines — replaces all recipe and material lines."}},"required":["id","production_date","location_id","recipes"]},"module":"work_orders","safety":"write"},{"name":"delete_work_order","description":"DELETE /api/work-orders/:id. Why: Permanently delete a DRAFT work order with its recipe and material lines (no stock was moved by a draft). The server does not check the status — never use it on in_progress / completed / cancelled orders (the dashboard cancels those instead, which has no MCP tool). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"delete_work_order\"} BEFORE calling this tool. Safety: destructive. customer_id is taken from the MCP session — do not send it.","endpoint":"DELETE /api/work-orders/:id","why":"Permanently delete a DRAFT work order with its recipe and material lines (no stock was moved by a draft). The server does not check the status — never use it on in_progress / completed / cancelled orders (the dashboard cancels those instead, which has no MCP tool).","response":{"description":"200 → { success: true, message: \"Work order deleted successfully\", data: { success: true } }. Unknown id / another tenant's → 500 { success: false, message: \"Not found\", error: \"Not found\" }. Transactional. Any status is deleted.","fields":{"success":"boolean. true on delete; false on error.","message":"string. \"Work order deleted successfully\" | \"Not found\" | DB error text.","data":"object. { success: true } on delete.","data.success":"boolean. true.","error":"string. Error text (500 only)."},"example":{"success":true,"message":"Work order deleted successfully","data":{"success":true}},"master_rules":["Dashboard: the detail page's \"Delete Order\" button (draft only) asks \"Are you sure you want to delete this work order? This action cannot be undone.\"; for in_progress orders the same button is \"Cancel Order\" and sets status cancelled instead. The history table's delete icon is disabled in code. Ask the user to confirm the same way.","Only delete when get_work_order header.status is \"draft\". Deleting a completed order would erase the document while its stock movements (reference WORK_ORDER_<uuid>) stay; for an in_progress order ask the user to cancel it in the dashboard.","Verify with get_work_order (→ \"Work order not found\") or list_work_orders."]},"parameters":{"type":"object","properties":{"id":{"type":"number","description":"Work order id (list_work_orders); must be a draft — check get_work_order header.status first."}},"required":["id"]},"module":"work_orders","safety":"destructive"},{"name":"list_users","description":"GET /api/users. Why: Staff users (the people who log into the dashboard and the POS) with their role, branches and every per-user POS switch — the admin view of the users table. Admin-only: the key's user must have is_admin 1 or role admin/super, else 401. Use it to get user ids for get_user / update_user / assign_user_role, for sync_invoice created_by_user_id / close_by_user_id and for list_orders_history user_id; role_id links to list_roles, location_id / allow_locations to list_locations, brand_id to list_selling_brands, channel_id to list_selling_channels. For the POS view of the session branch (setting object, devices) use list_pos_users; to change the session user's own POS switches use update_user_permissions; decoded ios_modules / max discount of the session user: get_user_extras. Customers are the CRM tools, not this. Rows carry the clear-text PIN — never show it. Without loc the server lists only the session user's branch (plus multi-branch users); for every user pass loc = comma-separated ids from list_locations. Bare array, no paging. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_users\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/users","why":"Staff users (the people who log into the dashboard and the POS) with their role, branches and every per-user POS switch — the admin view of the users table. Admin-only: the key's user must have is_admin 1 or role admin/super, else 401. Use it to get user ids for get_user / update_user / assign_user_role, for sync_invoice created_by_user_id / close_by_user_id and for list_orders_history user_id; role_id links to list_roles, location_id / allow_locations to list_locations, brand_id to list_selling_brands, channel_id to list_selling_channels. For the POS view of the session branch (setting object, devices) use list_pos_users; to change the session user's own POS switches use update_user_permissions; decoded ios_modules / max discount of the session user: get_user_extras. Customers are the CRM tools, not this. Rows carry the clear-text PIN — never show it.","response":{"description":"200 → bare array of user rows (columns below; image rewritten to an absolute URL, plus web_image and two flags decoded from ios_modules). Tenant = session. Only when the session user has no branch AND loc is omitted, the server returns slim rows { id, usename, email } instead. Errors: 401 { data: { error: \"Unauthorized access!\" } } for non-admin keys. Verified live on dev1 (tenant 437): dev1 also returns override_shift and does not return row_discount, shift_z_report, view_estimated_cash, external_order_ready yet.","fields":{"[]":"array. One object per staff user.","[].id":"int. User id. Pass as `id` to get_user / update_user and `user_id` to assign_user_role; the same id is sync_invoice created_by_user_id / close_by_user_id and list_orders_history user_id.","[].username":"string | null. Display name (dashboard field \"Name\"); unique per tenant. Personal data.","[].email":"string | null. Login email (web-pos and the dashboard log in with email + password); unique per tenant. Personal data.","[].pin":"string | null. The user's 4-digit POS PIN in clear text. NEVER show, repeat, log or store it; no tool needs it. Changed only by the dashboard Reset PIN button (no MCP tool).","[].role":"string | null. Lower-cased role name: \"admin\" / \"staff\", or a custom role name when saved through update_user with role_id. Legacy text — role_id decides dashboard permissions.","[].role_id":"int | null. Role (list_roles / get_role id). Its permissions (plus any direct user permissions) are what the dashboard lets this user open.","[].is_admin":"0 | 1. 1 = admin: may call the admin-only routes (list_users, get_user, update_user, update_user_permissions). update_user sets 1 for every role name except exactly \"staff\".","[].location_id":"int | null. Default branch (list_locations id). The dashboard saves the first ticked branch here.","[].customer_id":"int. Tenant (merchant) id — always your own tenant; not needed.","[].allow_locations":"string | null. CSV of the branch ids this user may use (list_locations), e.g. \"907,909,908\". \"\" / null = none recorded.","[].organization_customer_ids":"string | null. CSV of linked tenant ids (multi-tenant organisations; dashboard \"Linked Customers\"). \"\" / null = none. Not needed.","[].organization_id":"int | null. Organisation id for multi-tenant organisations. Not needed.","[].is_daily_report":"0 | 1 | null. Email this user the daily sales report.","[].is_weekly_report":"0 | 1 | null. Email this user the weekly report.","[].is_monthly_report":"0 | 1 | null. Email this user the monthly report.","[].modules":"string. ';'-separated 0/1 dashboard/app module flags by position: 0 Dashboard, 1 POS, 2 KDS, 3 Sales, 4 Reporting, 5 Products, 6 Inventory, 7 Config, 8 Preview Invoice, 9 Attendance, 10 (unlabelled). e.g. \"1;0;1;1;1;1;1;1;1;1;1\".","[].ios_modules":"string | null. ';'-separated 0/1 app feature flags by position: 0 is_kds_enable, 1 is_daily_sale_report_enable, 2 is_order_hisotry_screen_enable, 3 is_sms_enable, 4 is_cloud_print_enable, 5 is_user_blocked, 6 is_branding_enable, 7 is_actual_price, 8 is_usd_base, 9 is_dine_in_enable, 10 is_wastage_enable, 11 is_material_stock_enable, 12 is_balance_enable, 13 is_product_scanner_enable, 14 is_bar_scanner_enable, 15 is_devices_enable, 16 is_peer_enable, 17 is_setting_enable, 18 is_loyalty_enable, 19 is_ticket_enabled, 20 is_wallet_enabled, 21 is_location_service_enabled, 22 is_crm_mobile_mandatory, 23 reload_dine_order, 24 order_dispatch_feature. Shorter strings are common: missing positions read as 1 for positions 9-17 and 21, else 0. The session user's decoded flags: get_user_extras ios_modules.","[].can_preview_invoice":"0 | 1 | null. May preview invoices.","[].max_discount_permit":"int | null. Highest manual discount % this user may give at the POS (0-100; web-pos discount dialog). For the session user also in get_user_extras.","[].image":"string. Absolute URL of the profile image, or \"\" when none.","[].permissions":"string | null. ';'-separated 0/1 POS switches by position (NOT role permissions): 0 print_receipt, 1 kds_auto_receipt, 2 invoice_auto_receipt, 3 cart_options, 4 print_and_save, 5 cloud_print, 6 quit_printers_with_cloud_enabled, 7 invoice_printer_act_as_kds, 8 cart_note, 9 cart_car_plate, 10 token_number, 11 discount_codes, 12 transfer_order_pincode, 13 daily_sale_input_check, 14 daily_sale_media_check, 15 active_pincode, 16 label_printing, 17 qrcode_on_label_print, 18 is_kds_device, 19 open_drawer, 20 user_card_digits, 21 invoice_qrcode, 22 force_adons, 23 combo_options, 24 ensure_combo_minimum_item_requirement, 25 row_discount. Same switches as list_pos_users data[].setting and update_user_permissions (which edits them one key at a time for the session user).","[].print_no_of_times":"int. Receipt copies to print.","[].is_ingredient_disabled":"0 | 1. Dashboard checkbox \"Show Ingredients\" (ingredient display at the POS).","[].brand_id":"int. Default brand the POS opens with (list_selling_brands brand_id; 0 = none, web-pos then keeps the last/first brand).","[].channel_id":"int. Default sales channel (list_selling_channels channel_id; 0 = none).","[].fraction_digits":"int. Quantity fraction digits (not money decimals — those are get_user_extras decimal_points).","[].dining_guest_count":"0 | 1. Ask the guest count on dine-in orders (sync_invoice dining_guests).","[].is_brand_groups":"0 | 1. Brand groups UI at the POS.","[].is_extras_enble":"0 | 1. Extras (materials) UI at the POS (server spelling).","[].display_modifier_titles":"0 | 1. Show modifier group titles at the POS.","[].modifier_sort_min_required_desc":"0 | 1. Sort modifier groups by min required, descending.","[].print_only_save_kds_orders":"0 | 1. Print only saved KDS orders.","[].print_note_on_receipt":"0 | 1. Print the order note on the receipt.","[].orderhistory_print_receipt":"0 | 1. Allow printing from order history.","[].show_receipt_screen":"0 | 1. Show the receipt screen after payment.","[].order_type":"0 | 1. Order-type selector on (default order-type toggle).","[].open_dine_in":"0 | 1. Hold orders / open dine-in on start (dashboard label \"Hold Orders\").","[].hide_cart_pay_button":"0 | 1. Hide the cart PAY button.","[].qr_code_on_print_receipt":"0 | 1. QR code on the printed receipt.","[].is_channels_enable":"0 | 1. Sales channels on at the POS (list_selling_channels; also scopes discount codes by channel).","[].tip_amount":"0 | 1. Tips on overpay (sync_invoice tip_amount).","[].print_void_orders":"0 | 1. Print void orders (invoice and KDS).","[].has_void_order_permission":"0 | 1. May void orders (Android).","[].delivery_detail_enable":"0 | 1. Delivery details form on delivery orders.","[].is_attributed_receipt":"0 | 1. Attributed receipt layout.","[].exchange_format":"0 | 1. Exchange-rate display format.","[].is_cds_enable":"0 | 1. Customer display screen (CDS).","[].external_order_print_receipt":"0 | 1. Print external (aggregator) orders.","[].is_multi_location_allowed":"0 | 1. Multi-branch user: listed by list_users for EVERY branch filter.","[].is_master_locations_enabled":"0 | 1. Tenant runs master-location mode. Set by SysPOS; update_user does not change it.","[].is_qr_payment":"0 | 1. QR payment.","[].is_kds_expeditor":"0 | 1. KDS expeditor.","[].unify_brands":"0 | 1. Show all brands' products together.","[].text_receipt":"0 | 1. Text receipt.","[].each_category_kds_print":"0 | 1. One KDS ticket per category.","[].dinein_serving_role":"0 | 1. Dine-in serving role.","[].receipt_format":"0 | 1 | 2 | 3. Receipt template.","[].ingredient_stay_standalone":"0 | 1. Ingredients stay standalone.","[].reservation_with_dine_in":"0 | 1. Reservations with dine-in.","[].pay_zero_amount":"0 | 1. Zero-payable orders complete without payment.","[].receipt_translation":"0 | 1. Receipt translation.","[].product_translation":"0 | 1. Product name translation.","[].category_translation":"0 | 1. Category name translation.","[].print_invoice_translation":"0 | 1. Invoice translation.","[].print_kds_translation":"0 | 1. KDS translation.","[].print_deleted_item_on_receipt":"0 | 1. Print deleted items on the receipt.","[].draft_order":"0 | 1. Draft orders.","[].splitefire":"0 | 1. KDS split fire (server spelling).","[].allegy_item":"0 | 1. Allergy items (server spelling).","[].hide_clear_checkout":"0 | 1. Hide clear checkout.","[].kds_instruction":"0 | 1. Print KDS instructions.","[].pay_default_usd":"0 | 1. Pay in USD by default.","[].rpt_business_logo":"0 | 1. Receipt: business logo.","[].rpt_order_type":"0 | 1. Receipt: order type.","[].rpt_is_modified":"0 | 1. Receipt: modified mark.","[].rpt_biller_info":"0 | 1. Receipt: biller info.","[].rpt_total_before_vat":"0 | 1. Receipt: total before VAT.","[].rpt_vat_info":"0 | 1. Receipt: VAT info.","[].rpt_net_total":"0 | 1. Receipt: net total.","[].rpt_exchange_rate":"0 | 1. Receipt: exchange rate.","[].rpt_dine_guest_count":"0 | 1. Receipt: dine-in guest count.","[].rpt_paid_at":"0 | 1. Receipt: paid-at time.","[].esc_command":"0 | 1. ESC/POS printer commands.","[].one_table_one_order":"0 | 1. One open order per table.","[].is_socket_server":"0 | 1. This user's device is the peer socket server.","[].is_client":"0 | 1. Peer socket client.","[].web_local_network_printer":"0 | 1. Web local-network printer.","[].open_price_pincode":"0 | 1. PIN before an open-price item.","[].is_transfer_enabled":"0 | 1. Transfer-order button.","[].block_print_until_order_open":"0 | 1. Block daily-sales print until an order is open.","[].user_product_sales_counter":"0 | 1. Product sold counter.","[].is_change_payment_method_enabled":"0 | 1. May change the payment method of paid orders (update_invoice_payment_methods).","[].skip_zero_quantity_items":"0 | 1. Skip zero-quantity lines when printing.","[].skip_zero_amount_items":"0 | 1. Skip zero-amount lines when printing.","[].settle_wallet_topup":"0 | 1. Settle wallet top-ups.","[].sms_invoice":"0 | 1. SMS invoice.","[].offline_order_sync":"0 | 1. Offline order sync.","[].is_shift_management":"0 | 1. Shift management switch (shift tools are out of scope).","[].acknowledge_dine_table_edit":"0 | 1. Confirm dine table edits.","[].lock_screen":"0 | 1. Lock screen (update_user saves only 0 or 1).","[].force_crm_user":"0 | 1. A customer must be attached to every order (Android). Forced 1 by assign_user_role for admin-named roles.","[].shift_transactions":"0 | 1. Shift history (shift tools out of scope). Forced 1 when the role name contains \"admin\".","[].eod_shift_report":"0 | 1. End-of-day shift card (out of scope). Forced 1 when the role name contains \"admin\".","[].row_discount":"0 | 1. Per-line discount at the POS (also position 25 of `permissions`). (In the current source; the dev1 build does not return it yet.)","[].shift_z_report":"0 | 1. Z report (out of scope). Forced 1 when the role name contains \"admin\". (In the current source; the dev1 build does not return it yet.)","[].view_estimated_cash":"0 | 1. Estimated cash (out of scope). Forced 1 when the role name contains \"admin\". (In the current source; the dev1 build does not return it yet.)","[].external_order_ready":"0 | 1. Aggregator order-ready push. Forced 1 when the role name contains \"admin\". (In the current source; the dev1 build does not return it yet.)","[].override_shift":"0 | 1. Returned by the dev1 build only (not in the current source select). Shift override flag; shifts are out of scope.","[].web_image":"string. Relative image path \"api/uploads/<file>\", or \"\" when none.","[].is_crm_mobile_mandatory":"0 | 1. Decoded from ios_modules position 22: customer mobile number required at the POS.","[].reload_dine_order":"0 | 1. Decoded from ios_modules position 23: reload the dine-in order.","[].usename":"string. Only in the slim fallback rows (session user without a branch and no loc): the username, misspelled by the server."},"example":[{"id":4789,"username":"Sara Cashier","email":"sara.cashier@example.com","pin":"0000","role":"staff","role_id":32,"is_admin":0,"location_id":907,"customer_id":437,"allow_locations":"907,909,908","organization_customer_ids":null,"organization_id":null,"is_daily_report":0,"is_weekly_report":0,"is_monthly_report":1,"modules":"1;1;1;1;1;1;1;1;1;1;1","ios_modules":"1;1;1;1;1;1;1;1;1","can_preview_invoice":1,"max_discount_permit":26,"image":"","permissions":"1;0;1;1;0;0;0;0;1;1;0;0;0;0;0;1;0;0;0;1;0;0;0","print_no_of_times":1,"is_ingredient_disabled":0,"brand_id":0,"channel_id":0,"fraction_digits":2,"dining_guest_count":0,"is_brand_groups":0,"is_extras_enble":1,"display_modifier_titles":1,"modifier_sort_min_required_desc":1,"print_only_save_kds_orders":1,"print_note_on_receipt":1,"orderhistory_print_receipt":1,"show_receipt_screen":1,"order_type":1,"open_dine_in":0,"hide_cart_pay_button":0,"qr_code_on_print_receipt":0,"is_channels_enable":0,"tip_amount":1,"print_void_orders":0,"has_void_order_permission":1,"delivery_detail_enable":0,"is_attributed_receipt":1,"exchange_format":0,"is_cds_enable":null,"external_order_print_receipt":0,"is_multi_location_allowed":1,"is_master_locations_enabled":1,"is_qr_payment":0,"is_kds_expeditor":0,"unify_brands":0,"text_receipt":0,"each_category_kds_print":0,"dinein_serving_role":0,"receipt_format":0,"ingredient_stay_standalone":0,"reservation_with_dine_in":0,"pay_zero_amount":0,"receipt_translation":0,"product_translation":0,"category_translation":0,"print_invoice_translation":0,"print_kds_translation":0,"print_deleted_item_on_receipt":0,"draft_order":0,"splitefire":0,"allegy_item":0,"hide_clear_checkout":1,"kds_instruction":0,"pay_default_usd":1,"rpt_business_logo":1,"rpt_order_type":1,"rpt_is_modified":1,"rpt_biller_info":1,"rpt_total_before_vat":1,"rpt_vat_info":1,"rpt_net_total":1,"rpt_exchange_rate":0,"rpt_dine_guest_count":0,"rpt_paid_at":0,"esc_command":0,"one_table_one_order":0,"is_socket_server":0,"is_client":0,"web_local_network_printer":0,"open_price_pincode":1,"is_transfer_enabled":0,"block_print_until_order_open":0,"user_product_sales_counter":0,"is_change_payment_method_enabled":0,"skip_zero_quantity_items":0,"skip_zero_amount_items":0,"settle_wallet_topup":0,"sms_invoice":0,"offline_order_sync":1,"is_shift_management":0,"acknowledge_dine_table_edit":0,"lock_screen":0,"force_crm_user":0,"shift_transactions":0,"eod_shift_report":0,"override_shift":0,"web_image":"","is_crm_mobile_mandatory":0,"reload_dine_order":0}],"master_rules":["The dashboard uses this route only as a user picker: sales and discount reports send { _limit: 10000, loc } (loc omitted when \"All\"), waiter performance { _limit: 1000 }, activity log { _limit: 200 }, attendance { loc }, the balance credit form { loc, _limit: 10000 } and the user edit form's \"Cloud print target user\" list (no params). Each option shows username (else email) and sends the id.","The dashboard Users page (/dashboard/customers) lists users from a different route (GET /api/users-find, no MCP tool): nothing loads until a branch is chosen (default the viewer's own branch), the search box filters name/email/phone in the browser and pages 10/25/50 client-side. To reproduce it call list_users with loc = that branch and filter yourself.","Each Users-page row opens the edit form (get_user + update_user); Add opens the create form and the row delete button deletes — both are dashboard-only (no MCP create/delete user tool). Import/Export users are dashboard-only too.","pin is the real PIN: never show, repeat or log it — mask it (\"••••\") if a user asks. PIN reset is dashboard-only.","Read the user's branches with list_locations (allow_locations csv, location_id = default), the role with get_role (role_id), the default brand with list_selling_brands (brand_id). modules / ios_modules / permissions are positional ';' strings — decode by position, never by guessing.","POS linkage: the same user ids appear in list_pos_users (the branch's POS users with setting objects), as sync_invoice created_by_user_id / close_by_user_id and as the list_orders_history user_id filter. Per-user POS switches of the SESSION user are changed with update_user_permissions (one key per call); another user's switches only with update_user (full record)."]},"parameters":{"type":"object","properties":{"loc":{"type":"string","description":"Branch id or comma-separated branch ids (list_locations)."},"_limit":{"type":"number","description":"Max rows (1-50000); anything else is ignored (no limit)."}},"required":[]},"module":"users","safety":"read"},{"name":"get_user","description":"GET /api/users/:id. Why: One staff user's full record — what the dashboard user edit form loads before update_user: the list_users columns plus language, Zebra printer, cloud-print target, KDS station, is_soldout and the branch currency. Admin-only. Currently fails through MCP (HTTP 500): this route has no Bearer-token auth, it only works with a dashboard login cookie. Use list_users (loc = all branch ids) and pick the row by id instead. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_user\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/users/:id","why":"One staff user's full record — what the dashboard user edit form loads before update_user: the list_users columns plus language, Zebra printer, cloud-print target, KDS station, is_soldout and the branch currency. Admin-only.","response":{"description":"200 → bare array with one user row, [] when the id is not a user of your tenant. image is the raw stored file name (not a URL) and there is no web_image / decoded flags. Non-admin → 401 { data: { error: \"Unauthorized access!\" } }. Called with the MCP Bearer key the route has no auth middleware, so the handler crashes on the missing session user and Express answers 500 (HTML error page) — not called live for this reason; shape is from the handler's SELECT.","fields":{"[]":"array. [user] or [] (unknown id / other tenant).","[].id":"int. User id. Pass as `id` to get_user / update_user and `user_id` to assign_user_role; the same id is sync_invoice created_by_user_id / close_by_user_id and list_orders_history user_id.","[].username":"string | null. Display name (dashboard field \"Name\"); unique per tenant. Personal data.","[].email":"string | null. Login email (web-pos and the dashboard log in with email + password); unique per tenant. Personal data.","[].pin":"string | null. The user's 4-digit POS PIN in clear text. NEVER show, repeat, log or store it; no tool needs it. Changed only by the dashboard Reset PIN button (no MCP tool).","[].role":"string | null. Lower-cased role name: \"admin\" / \"staff\", or a custom role name when saved through update_user with role_id. Legacy text — role_id decides dashboard permissions.","[].role_id":"int | null. Role (list_roles / get_role id). Its permissions (plus any direct user permissions) are what the dashboard lets this user open.","[].is_admin":"0 | 1. 1 = admin: may call the admin-only routes (list_users, get_user, update_user, update_user_permissions). update_user sets 1 for every role name except exactly \"staff\".","[].location_id":"int | null. Default branch (list_locations id). The dashboard saves the first ticked branch here.","[].customer_id":"int. Tenant (merchant) id — always your own tenant; not needed.","[].allow_locations":"string | null. CSV of the branch ids this user may use (list_locations), e.g. \"907,909,908\". \"\" / null = none recorded.","[].organization_customer_ids":"string | null. CSV of linked tenant ids (multi-tenant organisations; dashboard \"Linked Customers\"). \"\" / null = none. Not needed.","[].organization_id":"int | null. Organisation id for multi-tenant organisations. Not needed.","[].is_daily_report":"0 | 1 | null. Email this user the daily sales report.","[].is_weekly_report":"0 | 1 | null. Email this user the weekly report.","[].is_monthly_report":"0 | 1 | null. Email this user the monthly report.","[].modules":"string. ';'-separated 0/1 dashboard/app module flags by position: 0 Dashboard, 1 POS, 2 KDS, 3 Sales, 4 Reporting, 5 Products, 6 Inventory, 7 Config, 8 Preview Invoice, 9 Attendance, 10 (unlabelled). e.g. \"1;0;1;1;1;1;1;1;1;1;1\".","[].ios_modules":"string | null. ';'-separated 0/1 app feature flags by position: 0 is_kds_enable, 1 is_daily_sale_report_enable, 2 is_order_hisotry_screen_enable, 3 is_sms_enable, 4 is_cloud_print_enable, 5 is_user_blocked, 6 is_branding_enable, 7 is_actual_price, 8 is_usd_base, 9 is_dine_in_enable, 10 is_wastage_enable, 11 is_material_stock_enable, 12 is_balance_enable, 13 is_product_scanner_enable, 14 is_bar_scanner_enable, 15 is_devices_enable, 16 is_peer_enable, 17 is_setting_enable, 18 is_loyalty_enable, 19 is_ticket_enabled, 20 is_wallet_enabled, 21 is_location_service_enabled, 22 is_crm_mobile_mandatory, 23 reload_dine_order, 24 order_dispatch_feature. Shorter strings are common: missing positions read as 1 for positions 9-17 and 21, else 0. The session user's decoded flags: get_user_extras ios_modules.","[].can_preview_invoice":"0 | 1 | null. May preview invoices.","[].max_discount_permit":"int | null. Highest manual discount % this user may give at the POS (0-100; web-pos discount dialog). For the session user also in get_user_extras.","[].image":"string | null. Stored image file name (users.thumbnail, no URL), null when none.","[].permissions":"string | null. ';'-separated 0/1 POS switches by position (NOT role permissions): 0 print_receipt, 1 kds_auto_receipt, 2 invoice_auto_receipt, 3 cart_options, 4 print_and_save, 5 cloud_print, 6 quit_printers_with_cloud_enabled, 7 invoice_printer_act_as_kds, 8 cart_note, 9 cart_car_plate, 10 token_number, 11 discount_codes, 12 transfer_order_pincode, 13 daily_sale_input_check, 14 daily_sale_media_check, 15 active_pincode, 16 label_printing, 17 qrcode_on_label_print, 18 is_kds_device, 19 open_drawer, 20 user_card_digits, 21 invoice_qrcode, 22 force_adons, 23 combo_options, 24 ensure_combo_minimum_item_requirement, 25 row_discount. Same switches as list_pos_users data[].setting and update_user_permissions (which edits them one key at a time for the session user).","[].print_no_of_times":"int. Receipt copies to print.","[].is_ingredient_disabled":"0 | 1. Dashboard checkbox \"Show Ingredients\" (ingredient display at the POS).","[].brand_id":"int. Default brand the POS opens with (list_selling_brands brand_id; 0 = none, web-pos then keeps the last/first brand).","[].channel_id":"int. Default sales channel (list_selling_channels channel_id; 0 = none).","[].fraction_digits":"int. Quantity fraction digits (not money decimals — those are get_user_extras decimal_points).","[].dining_guest_count":"0 | 1. Ask the guest count on dine-in orders (sync_invoice dining_guests).","[].is_brand_groups":"0 | 1. Brand groups UI at the POS.","[].is_extras_enble":"0 | 1. Extras (materials) UI at the POS (server spelling).","[].display_modifier_titles":"0 | 1. Show modifier group titles at the POS.","[].modifier_sort_min_required_desc":"0 | 1. Sort modifier groups by min required, descending.","[].print_only_save_kds_orders":"0 | 1. Print only saved KDS orders.","[].print_note_on_receipt":"0 | 1. Print the order note on the receipt.","[].orderhistory_print_receipt":"0 | 1. Allow printing from order history.","[].show_receipt_screen":"0 | 1. Show the receipt screen after payment.","[].order_type":"0 | 1. Order-type selector on (default order-type toggle).","[].open_dine_in":"0 | 1. Hold orders / open dine-in on start (dashboard label \"Hold Orders\").","[].hide_cart_pay_button":"0 | 1. Hide the cart PAY button.","[].qr_code_on_print_receipt":"0 | 1. QR code on the printed receipt.","[].is_channels_enable":"0 | 1. Sales channels on at the POS (list_selling_channels; also scopes discount codes by channel).","[].tip_amount":"0 | 1. Tips on overpay (sync_invoice tip_amount).","[].print_void_orders":"0 | 1. Print void orders (invoice and KDS).","[].has_void_order_permission":"0 | 1. May void orders (Android).","[].delivery_detail_enable":"0 | 1. Delivery details form on delivery orders.","[].is_attributed_receipt":"0 | 1. Attributed receipt layout.","[].exchange_format":"0 | 1. Exchange-rate display format.","[].is_cds_enable":"0 | 1. Customer display screen (CDS).","[].external_order_print_receipt":"0 | 1. Print external (aggregator) orders.","[].is_multi_location_allowed":"0 | 1. Multi-branch user: listed by list_users for EVERY branch filter.","[].is_master_locations_enabled":"0 | 1. Tenant runs master-location mode. Set by SysPOS; update_user does not change it.","[].is_qr_payment":"0 | 1. QR payment.","[].is_kds_expeditor":"0 | 1. KDS expeditor.","[].unify_brands":"0 | 1. Show all brands' products together.","[].text_receipt":"0 | 1. Text receipt.","[].each_category_kds_print":"0 | 1. One KDS ticket per category.","[].dinein_serving_role":"0 | 1. Dine-in serving role.","[].receipt_format":"0 | 1 | 2 | 3. Receipt template.","[].ingredient_stay_standalone":"0 | 1. Ingredients stay standalone.","[].reservation_with_dine_in":"0 | 1. Reservations with dine-in.","[].pay_zero_amount":"0 | 1. Zero-payable orders complete without payment.","[].receipt_translation":"0 | 1. Receipt translation.","[].product_translation":"0 | 1. Product name translation.","[].category_translation":"0 | 1. Category name translation.","[].print_invoice_translation":"0 | 1. Invoice translation.","[].print_kds_translation":"0 | 1. KDS translation.","[].print_deleted_item_on_receipt":"0 | 1. Print deleted items on the receipt.","[].draft_order":"0 | 1. Draft orders.","[].splitefire":"0 | 1. KDS split fire (server spelling).","[].allegy_item":"0 | 1. Allergy items (server spelling).","[].hide_clear_checkout":"0 | 1. Hide clear checkout.","[].kds_instruction":"0 | 1. Print KDS instructions.","[].pay_default_usd":"0 | 1. Pay in USD by default.","[].rpt_business_logo":"0 | 1. Receipt: business logo.","[].rpt_order_type":"0 | 1. Receipt: order type.","[].rpt_is_modified":"0 | 1. Receipt: modified mark.","[].rpt_biller_info":"0 | 1. Receipt: biller info.","[].rpt_total_before_vat":"0 | 1. Receipt: total before VAT.","[].rpt_vat_info":"0 | 1. Receipt: VAT info.","[].rpt_net_total":"0 | 1. Receipt: net total.","[].rpt_exchange_rate":"0 | 1. Receipt: exchange rate.","[].rpt_dine_guest_count":"0 | 1. Receipt: dine-in guest count.","[].rpt_paid_at":"0 | 1. Receipt: paid-at time.","[].esc_command":"0 | 1. ESC/POS printer commands.","[].one_table_one_order":"0 | 1. One open order per table.","[].is_socket_server":"0 | 1. This user's device is the peer socket server.","[].is_client":"0 | 1. Peer socket client.","[].web_local_network_printer":"0 | 1. Web local-network printer.","[].open_price_pincode":"0 | 1. PIN before an open-price item.","[].is_transfer_enabled":"0 | 1. Transfer-order button.","[].block_print_until_order_open":"0 | 1. Block daily-sales print until an order is open.","[].user_product_sales_counter":"0 | 1. Product sold counter.","[].is_change_payment_method_enabled":"0 | 1. May change the payment method of paid orders (update_invoice_payment_methods).","[].skip_zero_quantity_items":"0 | 1. Skip zero-quantity lines when printing.","[].skip_zero_amount_items":"0 | 1. Skip zero-amount lines when printing.","[].settle_wallet_topup":"0 | 1. Settle wallet top-ups.","[].sms_invoice":"0 | 1. SMS invoice.","[].offline_order_sync":"0 | 1. Offline order sync.","[].is_shift_management":"0 | 1. Shift management switch (shift tools are out of scope).","[].acknowledge_dine_table_edit":"0 | 1. Confirm dine table edits.","[].lock_screen":"0 | 1. Lock screen (update_user saves only 0 or 1).","[].force_crm_user":"0 | 1. A customer must be attached to every order (Android). Forced 1 by assign_user_role for admin-named roles.","[].ios_language":"string | null. App language code, e.g. \"en\" (update_user body key `language`).","[].zebra_ip":"string | null. Zebra label-printer IP.","[].zebra_port":"int | null. Zebra label-printer port.","[].cloud_target_user_id":"int | null. User whose device receives this user's cloud prints (0/null = none).","[].cloud_target_device_id":"string | null. Device that receives this user's cloud prints (null = none).","[].station_id":"int. KDS station id (0 = none).","[].is_soldout":"0 | 1. May mark products sold out at the POS (update_product_availability).","[].shift_transactions":"0 | 1. Shift history (shift tools out of scope). Forced 1 when the role name contains \"admin\".","[].eod_shift_report":"0 | 1. End-of-day shift card (out of scope). Forced 1 when the role name contains \"admin\".","[].row_discount":"0 | 1. Per-line discount at the POS (also position 25 of `permissions`). (In the current source; the dev1 build does not return it yet.)","[].shift_z_report":"0 | 1. Z report (out of scope). Forced 1 when the role name contains \"admin\". (In the current source; the dev1 build does not return it yet.)","[].view_estimated_cash":"0 | 1. Estimated cash (out of scope). Forced 1 when the role name contains \"admin\". (In the current source; the dev1 build does not return it yet.)","[].external_order_ready":"0 | 1. Aggregator order-ready push. Forced 1 when the role name contains \"admin\". (In the current source; the dev1 build does not return it yet.)","[].currency_code":"string | null. Branch config `currency` of the user's location_id (config table, not a users column); null when the branch has none."},"example":[{"id":4789,"username":"Sara Cashier","email":"sara.cashier@example.com","pin":"0000","role":"staff","role_id":32,"is_admin":0,"location_id":907,"customer_id":437,"allow_locations":"907,909,908","organization_customer_ids":null,"organization_id":null,"is_daily_report":0,"is_weekly_report":0,"is_monthly_report":1,"modules":"1;1;1;1;1;1;1;1;1;1;1","ios_modules":"1;1;1;1;1;1;1;1;1","can_preview_invoice":1,"max_discount_permit":26,"image":null,"permissions":"1;0;1;1;0;0;0;0;1;1;0;0;0;0;0;1;0;0;0;1;0;0;0","print_no_of_times":1,"is_ingredient_disabled":0,"brand_id":0,"channel_id":0,"fraction_digits":2,"dining_guest_count":0,"is_brand_groups":0,"is_extras_enble":1,"display_modifier_titles":1,"modifier_sort_min_required_desc":1,"print_only_save_kds_orders":1,"print_note_on_receipt":1,"orderhistory_print_receipt":1,"show_receipt_screen":1,"order_type":1,"open_dine_in":0,"hide_cart_pay_button":0,"qr_code_on_print_receipt":0,"is_channels_enable":0,"tip_amount":1,"print_void_orders":0,"has_void_order_permission":1,"delivery_detail_enable":0,"is_attributed_receipt":1,"exchange_format":0,"is_cds_enable":null,"external_order_print_receipt":0,"is_multi_location_allowed":1,"is_master_locations_enabled":1,"is_qr_payment":0,"is_kds_expeditor":0,"unify_brands":0,"text_receipt":0,"each_category_kds_print":0,"dinein_serving_role":0,"receipt_format":0,"ingredient_stay_standalone":0,"reservation_with_dine_in":0,"pay_zero_amount":0,"receipt_translation":0,"product_translation":0,"category_translation":0,"print_invoice_translation":0,"print_kds_translation":0,"print_deleted_item_on_receipt":0,"draft_order":0,"splitefire":0,"allegy_item":0,"hide_clear_checkout":1,"kds_instruction":0,"pay_default_usd":1,"rpt_business_logo":1,"rpt_order_type":1,"rpt_is_modified":1,"rpt_biller_info":1,"rpt_total_before_vat":1,"rpt_vat_info":1,"rpt_net_total":1,"rpt_exchange_rate":0,"rpt_dine_guest_count":0,"rpt_paid_at":0,"esc_command":0,"one_table_one_order":0,"is_socket_server":0,"is_client":0,"web_local_network_printer":0,"open_price_pincode":1,"is_transfer_enabled":0,"block_print_until_order_open":0,"user_product_sales_counter":0,"is_change_payment_method_enabled":0,"skip_zero_quantity_items":0,"skip_zero_amount_items":0,"settle_wallet_topup":0,"sms_invoice":0,"offline_order_sync":1,"is_shift_management":0,"acknowledge_dine_table_edit":0,"lock_screen":0,"force_crm_user":0,"ios_language":"en","zebra_ip":"","zebra_port":0,"cloud_target_user_id":null,"cloud_target_device_id":null,"station_id":0,"is_soldout":0,"shift_transactions":0,"eod_shift_report":0,"row_discount":0,"shift_z_report":0,"view_estimated_cash":0,"external_order_ready":0,"currency_code":"AED"}],"master_rules":["Until the route accepts the API key, get the record from list_users: loc = every id from list_locations, then pick the row by id. It has every column update_user needs except ios_language, zebra_ip, zebra_port, cloud_target_user_id, cloud_target_device_id, station_id, is_soldout and currency_code — which update_user keeps when you omit them.","The dashboard edit page (/dashboard/customers/<id>/edit) merges the list row with this record and fills the form: allow_locations split to a list (first = default branch), modules / ios_modules / permissions split on ';' (ios_modules padded to 25 with defaults, permissions padded to 26, position 25 overwritten by row_discount), brand_id / channel_id 0 shown as empty, print_no_of_times default 1, currency from currency_code.","The form header shows email, the initials of username and the user id; an Activity-log tab lists this user's actions (separate route, no MCP tool).","pin is the real PIN (the form shows it in a \"Current PIN\" chip): never show, repeat or log it.","Linkage: role_id → get_role; location_id / allow_locations → list_locations; brand_id → list_selling_brands; channel_id → list_selling_channels; is_soldout → update_product_availability; the POS view of the same user is list_pos_users."]},"parameters":{"type":"object","properties":{"id":{"type":"string","description":"User id (list_users [].id)."}},"required":["id"]},"module":"users","safety":"read"},{"name":"update_user","description":"PUT /api/users/:id. Why: Edit one staff user — what the dashboard user edit form saves: name, email, role (role_id from list_roles), branches (allow_locations + default location_id from list_locations), dashboard modules, app ios_modules, the positional POS switch string `permissions`, max discount, report e-mails, default brand (list_selling_brands) / channel and the named POS switches. Admin-only. It is a FULL-RECORD save: read the user with list_users first and resend every field. For the session user's own POS switches one at a time prefer update_user_permissions; role-only change: assign_user_role. Full replace: omitted role_id, allow_locations, permissions, max_discount_permit, report flags, image, organization_customer_ids → NULL; brand_id / dining_guest_count → 0; ios_modules → tenant default. Resend the list_users row. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_user\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/users/:id","why":"Edit one staff user — what the dashboard user edit form saves: name, email, role (role_id from list_roles), branches (allow_locations + default location_id from list_locations), dashboard modules, app ios_modules, the positional POS switch string `permissions`, max discount, report e-mails, default brand (list_selling_brands) / channel and the named POS switches. Admin-only. It is a FULL-RECORD save: read the user with list_users first and resend every field. For the session user's own POS switches one at a time prefer update_user_permissions; role-only change: assign_user_role.","response":{"description":"200 → { data: <your body echoed, normalised>, message: \"User updated successfully\" }. Errors (all { data: { error } }): 401 \"Unauthorized access!\" (non-admin key); 404 \"User not found.\"; 400 \"Invalid role_id: role not found\"; 400 \"Username already exists!\" / \"Email already exists!\"; 400 \"You can only assign locations from your allowed locations list.\"; 404 \"Customer not found.\"; 404 \"User not found or no changes made.\"; 500 \"An error occurred while updating the user. Please try again.\" (e.g. modules missing, an array in allow_locations / modules / ios_modules / permissions).","fields":{"data":"object. Echo of the body you sent after the server's normalisation — NOT a fresh read (re-read with list_users).","data.id":"string. The user id from the path (string even if you sent a number).","data.is_admin":"0 | 1. Added by the server: 0 only when the role name is exactly \"staff\".","data.role":"string | undefined. The role name lower-cased when role_id was sent, else what you sent (absent when neither).","data.brand_id":"int. 0 when you sent 0 / \"\" / nothing.","data.dining_guest_count":"0 | 1. Always present (0 when omitted).","data.modules":"string. Your modules string after positions 0-7 were clamped to the tenant's modules.","data.image":"string. Your image value cut to the part after \"uploads/\" (absent when not sent).","data.username":"Echo of the `username` you sent (absent when not sent).","data.email":"Echo of the `email` you sent (absent when not sent).","data.role_id":"Echo of the `role_id` you sent (absent when not sent).","data.location_id":"Echo of the `location_id` you sent (absent when not sent).","data.allow_locations":"Echo of the `allow_locations` you sent (absent when not sent).","data.ios_modules":"Echo of the `ios_modules` you sent (absent when not sent).","data.permissions":"Echo of the `permissions` you sent (absent when not sent).","data.max_discount_permit":"Echo of the `max_discount_permit` you sent (absent when not sent).","data.can_preview_invoice":"Echo of the `can_preview_invoice` you sent (absent when not sent).","data.is_daily_report":"Echo of the `is_daily_report` you sent (absent when not sent).","data.is_weekly_report":"Echo of the `is_weekly_report` you sent (absent when not sent).","data.is_monthly_report":"Echo of the `is_monthly_report` you sent (absent when not sent).","data.is_ingredient_disabled":"Echo of the `is_ingredient_disabled` you sent (absent when not sent).","data.organization_customer_ids":"Echo of the `organization_customer_ids` you sent (absent when not sent).","data.channel_id":"Echo of the `channel_id` you sent (absent when not sent).","data.print_no_of_times":"Echo of the `print_no_of_times` you sent (absent when not sent).","data.fraction_digits":"Echo of the `fraction_digits` you sent (absent when not sent).","data.receipt_format":"Echo of the `receipt_format` you sent (absent when not sent).","data.row_discount":"Echo of the `row_discount` you sent (absent when not sent).","data.language":"Echo of the `language` you sent (absent when not sent).","data.zebra_ip":"Echo of the `zebra_ip` you sent (absent when not sent).","data.zebra_port":"Echo of the `zebra_port` you sent (absent when not sent).","data.cloud_target_user_id":"Echo of the `cloud_target_user_id` you sent (absent when not sent).","data.cloud_target_device_id":"Echo of the `cloud_target_device_id` you sent (absent when not sent).","data.station_id":"Echo of the `station_id` you sent (absent when not sent).","data.currency":"Echo of the `currency` you sent (absent when not sent).","data.orderhistory_print_receipt":"Echo of the `orderhistory_print_receipt` you sent (absent when not sent).","data.show_receipt_screen":"Echo of the `show_receipt_screen` you sent (absent when not sent).","data.is_brand_groups":"Echo of the `is_brand_groups` you sent (absent when not sent).","data.is_extras_enble":"Echo of the `is_extras_enble` you sent (absent when not sent).","data.display_modifier_titles":"Echo of the `display_modifier_titles` you sent (absent when not sent).","data.modifier_sort_min_required_desc":"Echo of the `modifier_sort_min_required_desc` you sent (absent when not sent).","data.print_only_save_kds_orders":"Echo of the `print_only_save_kds_orders` you sent (absent when not sent).","data.print_note_on_receipt":"Echo of the `print_note_on_receipt` you sent (absent when not sent).","data.order_type":"Echo of the `order_type` you sent (absent when not sent).","data.open_dine_in":"Echo of the `open_dine_in` you sent (absent when not sent).","data.hide_cart_pay_button":"Echo of the `hide_cart_pay_button` you sent (absent when not sent).","data.tip_amount":"Echo of the `tip_amount` you sent (absent when not sent).","data.print_void_orders":"Echo of the `print_void_orders` you sent (absent when not sent).","data.has_void_order_permission":"Echo of the `has_void_order_permission` you sent (absent when not sent).","data.hide_clear_checkout":"Echo of the `hide_clear_checkout` you sent (absent when not sent).","data.kds_instruction":"Echo of the `kds_instruction` you sent (absent when not sent).","data.pay_default_usd":"Echo of the `pay_default_usd` you sent (absent when not sent).","data.rpt_business_logo":"Echo of the `rpt_business_logo` you sent (absent when not sent).","data.rpt_order_type":"Echo of the `rpt_order_type` you sent (absent when not sent).","data.rpt_is_modified":"Echo of the `rpt_is_modified` you sent (absent when not sent).","data.rpt_biller_info":"Echo of the `rpt_biller_info` you sent (absent when not sent).","data.rpt_total_before_vat":"Echo of the `rpt_total_before_vat` you sent (absent when not sent).","data.rpt_vat_info":"Echo of the `rpt_vat_info` you sent (absent when not sent).","data.rpt_net_total":"Echo of the `rpt_net_total` you sent (absent when not sent).","data.rpt_exchange_rate":"Echo of the `rpt_exchange_rate` you sent (absent when not sent).","data.rpt_dine_guest_count":"Echo of the `rpt_dine_guest_count` you sent (absent when not sent).","data.rpt_paid_at":"Echo of the `rpt_paid_at` you sent (absent when not sent).","data.esc_command":"Echo of the `esc_command` you sent (absent when not sent).","data.qr_code_on_print_receipt":"Echo of the `qr_code_on_print_receipt` you sent (absent when not sent).","data.delivery_detail_enable":"Echo of the `delivery_detail_enable` you sent (absent when not sent).","data.is_attributed_receipt":"Echo of the `is_attributed_receipt` you sent (absent when not sent).","data.exchange_format":"Echo of the `exchange_format` you sent (absent when not sent).","data.is_channels_enable":"Echo of the `is_channels_enable` you sent (absent when not sent).","data.is_cds_enable":"Echo of the `is_cds_enable` you sent (absent when not sent).","data.external_order_print_receipt":"Echo of the `external_order_print_receipt` you sent (absent when not sent).","data.text_receipt":"Echo of the `text_receipt` you sent (absent when not sent).","data.each_category_kds_print":"Echo of the `each_category_kds_print` you sent (absent when not sent).","data.user_product_sales_counter":"Echo of the `user_product_sales_counter` you sent (absent when not sent).","data.is_change_payment_method_enabled":"Echo of the `is_change_payment_method_enabled` you sent (absent when not sent).","data.skip_zero_quantity_items":"Echo of the `skip_zero_quantity_items` you sent (absent when not sent).","data.skip_zero_amount_items":"Echo of the `skip_zero_amount_items` you sent (absent when not sent).","data.settle_wallet_topup":"Echo of the `settle_wallet_topup` you sent (absent when not sent).","data.print_deleted_item_on_receipt":"Echo of the `print_deleted_item_on_receipt` you sent (absent when not sent).","data.block_print_until_order_open":"Echo of the `block_print_until_order_open` you sent (absent when not sent).","data.draft_order":"Echo of the `draft_order` you sent (absent when not sent).","data.open_price_pincode":"Echo of the `open_price_pincode` you sent (absent when not sent).","data.is_transfer_enabled":"Echo of the `is_transfer_enabled` you sent (absent when not sent).","data.sms_invoice":"Echo of the `sms_invoice` you sent (absent when not sent).","data.offline_order_sync":"Echo of the `offline_order_sync` you sent (absent when not sent).","data.is_multi_location_allowed":"Echo of the `is_multi_location_allowed` you sent (absent when not sent).","data.is_qr_payment":"Echo of the `is_qr_payment` you sent (absent when not sent).","data.is_kds_expeditor":"Echo of the `is_kds_expeditor` you sent (absent when not sent).","data.unify_brands":"Echo of the `unify_brands` you sent (absent when not sent).","data.dinein_serving_role":"Echo of the `dinein_serving_role` you sent (absent when not sent).","data.ingredient_stay_standalone":"Echo of the `ingredient_stay_standalone` you sent (absent when not sent).","data.reservation_with_dine_in":"Echo of the `reservation_with_dine_in` you sent (absent when not sent).","data.pay_zero_amount":"Echo of the `pay_zero_amount` you sent (absent when not sent).","data.receipt_translation":"Echo of the `receipt_translation` you sent (absent when not sent).","data.product_translation":"Echo of the `product_translation` you sent (absent when not sent).","data.category_translation":"Echo of the `category_translation` you sent (absent when not sent).","data.print_invoice_translation":"Echo of the `print_invoice_translation` you sent (absent when not sent).","data.print_kds_translation":"Echo of the `print_kds_translation` you sent (absent when not sent).","data.splitefire":"Echo of the `splitefire` you sent (absent when not sent).","data.allegy_item":"Echo of the `allegy_item` you sent (absent when not sent).","data.one_table_one_order":"Echo of the `one_table_one_order` you sent (absent when not sent).","data.is_socket_server":"Echo of the `is_socket_server` you sent (absent when not sent).","data.is_client":"Echo of the `is_client` you sent (absent when not sent).","data.web_local_network_printer":"Echo of the `web_local_network_printer` you sent (absent when not sent).","data.is_shift_management":"Echo of the `is_shift_management` you sent (absent when not sent).","data.shift_transactions":"Echo of the `shift_transactions` you sent (absent when not sent).","data.eod_shift_report":"Echo of the `eod_shift_report` you sent (absent when not sent).","data.shift_z_report":"Echo of the `shift_z_report` you sent (absent when not sent).","data.view_estimated_cash":"Echo of the `view_estimated_cash` you sent (absent when not sent).","data.external_order_ready":"Echo of the `external_order_ready` you sent (absent when not sent).","data.acknowledge_dine_table_edit":"Echo of the `acknowledge_dine_table_edit` you sent (absent when not sent).","data.lock_screen":"Echo of the `lock_screen` you sent (absent when not sent).","data.is_soldout":"Echo of the `is_soldout` you sent (absent when not sent).","data.force_crm_user":"Echo of the `force_crm_user` you sent (absent when not sent).","message":"string. \"User updated successfully\"."},"example_request":{"id":4789,"username":"Sara Cashier","email":"sara.cashier@example.com","role_id":32,"location_id":907,"allow_locations":"907,909,908","modules":"1;1;1;1;1;1;1;1;1;1;1","ios_modules":"1;1;1;1;1;1;1;1;1","permissions":"1;0;1;1;0;0;0;0;1;1;0;0;0;0;0;1;0;0;0;1;0;0;0;0;0;0","max_discount_permit":30,"can_preview_invoice":1,"is_daily_report":0,"is_weekly_report":0,"is_monthly_report":0,"is_ingredient_disabled":0,"image":"","organization_customer_ids":"","brand_id":0,"dining_guest_count":0},"example":{"data":{"id":"4789","username":"Sara Cashier","email":"sara.cashier@example.com","role_id":32,"location_id":907,"allow_locations":"907,909,908","modules":"1;1;1;1;1;1;1;1;1;1;1","ios_modules":"1;1;1;1;1;1;1;1;1","permissions":"1;0;1;1;0;0;0;0;1;1;0;0;0;0;0;1;0;0;0;1;0;0;0;0;0;0","max_discount_permit":30,"can_preview_invoice":1,"is_daily_report":0,"is_weekly_report":0,"is_monthly_report":0,"is_ingredient_disabled":0,"image":"","organization_customer_ids":"","brand_id":0,"dining_guest_count":0,"role":"staff","is_admin":0},"message":"User updated successfully"},"master_rules":["Start from the user's list_users row and resend it with your change. The dashboard edit form always sends the whole record (every column it loaded plus its form values), so it never resets anything; a partial body does.","Resets when omitted: role_id / role (→ NULL, is_admin 1), allow_locations, permissions, max_discount_permit, can_preview_invoice, is_daily_report, is_weekly_report, is_monthly_report, is_ingredient_disabled, image, organization_customer_ids (→ NULL); brand_id and dining_guest_count (→ 0); ios_modules (→ the tenant default). Every other named switch, channel_id, print_no_of_times, fraction_digits, language, zebra_*, cloud_target_*, station_id is kept when omitted.","Form → body (dashboard): Name → username, Email → email (both required, valid email), Max Discount → max_discount_permit, Password → password (optional on edit; not sent by MCP), Roles dropdown (master tenants; options from list_roles) → role_id, or Admin/Staff select → role on non-master tenants; Locations checklist (≥ 1 required, first ticked = default) → allow_locations csv + location_id; Modules / Permissions / IOS Modules checklists → ';' strings; POS & device panel → cloud_target_user_id (list_users), cloud_target_device_id, station_id, language, zebra_ip, zebra_port, currency, print_no_of_times (default 1), fraction_digits, brand_id (list_selling_brands of the first branch), channel_id, receipt_format; Show Ingredients → is_ingredient_disabled; Reports Daily/Weekly/Monthly → is_*_report; named POS switch checkboxes → their keys as 0/1.","Role effects: any role name other than exactly \"staff\" makes the user an admin (is_admin 1); a name containing \"admin\" also turns on the five shift/aggregator flags. Dashboard page access comes from the role's permissions (get_role) plus direct user permissions; POS behaviour from `permissions`, the named switches and ios_modules.","Send strings for allow_locations (csv), modules, ios_modules and permissions (';'), never JSON arrays. Keep permissions at 26 positions and ios_modules at its current length.","Omit currency (it rewrites the branch currency for everyone) and never send password or pin. Creating users, deleting users, resetting PINs, uploading images, linked customers and direct per-user permissions stay in the dashboard.","After saving, confirm with list_users. The POS picks up the change on the user's next login / refresh (list_pos_users shows the branch view)."]},"parameters":{"type":"object","properties":{"id":{"type":"string","description":"User id (list_users [].id)."},"username":{"type":"string","description":"Display name (dashboard \"Name\")."},"email":{"type":"string","description":"Login email."},"role_id":{"type":"string","description":"Role id (list_roles)."},"role":{"type":"string","enum":["admin","staff"],"description":"Legacy role text \"admin\" | \"staff\" (what non-master dashboards send)."},"location_id":{"type":"number","description":"Default branch id (list_locations) — one id, not csv."},"allow_locations":{"type":"string","description":"CSV of every branch id the user may use, e.g."},"modules":{"type":"string","description":"';'-separated 0/1 dashboard/app module flags by position: 0 Dashboard, 1 POS, 2 KDS, 3 Sales, 4 Reporting, 5 Products, 6 Inventory, 7 Config, 8 Preview Invoice…"},"ios_modules":{"type":"string","description":"';'-separated 0/1 app feature flags by position: 0 is_kds_enable, 1 is_daily_sale_report_enable, 2 is_order_hisotry_screen_enable, 3 is_sms_enable, 4 is_cloud_…"},"permissions":{"type":"string","description":"';'-separated 0/1 POS switches by position (NOT role permissions): 0 print_receipt, 1 kds_auto_receipt, 2 invoice_auto_receipt, 3 cart_options, 4 print_and_sav…"},"max_discount_permit":{"type":"number","description":"Max manual discount % at the POS (0-100)."},"can_preview_invoice":{"type":"number","description":"0 | 1."},"is_daily_report":{"type":"number","description":"0 | 1."},"is_weekly_report":{"type":"number","description":"0 | 1."},"is_monthly_report":{"type":"number","description":"0 | 1."},"is_ingredient_disabled":{"type":"number","description":"0 | 1."},"image":{"type":"string","description":"Profile image: the file name, or the list_users URL (the server keeps the part after \"uploads/\")."},"organization_customer_ids":{"type":"string","description":"CSV of linked tenant ids (dashboard \"Linked Customers\"); \"\" = none."},"brand_id":{"type":"number","description":"Default POS brand (list_selling_brands brand_id; 0 = none)."},"dining_guest_count":{"type":"number","description":"0 | 1."},"channel_id":{"type":"number","description":"Default sales channel (list_selling_channels channel_id; 0 = none)."},"print_no_of_times":{"type":"number","description":"Receipt copies (≥ 1)."},"fraction_digits":{"type":"number","description":"Quantity fraction digits (not money)."},"receipt_format":{"type":"number","description":"Receipt template 0-3; other values → 0."},"row_discount":{"type":"number","description":"0 | 1."},"language":{"type":"string","description":"App language code, e.g."},"zebra_ip":{"type":"string","description":"Zebra label-printer IP (null → \"\")."},"zebra_port":{"type":"number","description":"Zebra label-printer port (non-numbers → 0)."},"cloud_target_user_id":{"type":"number","description":"User id (list_users) whose device receives this user's cloud prints; 0 = none."},"cloud_target_device_id":{"type":"string","description":"Device id that receives this user's cloud prints; null = none."},"station_id":{"type":"number","description":"KDS station id (0 = none)."},"currency":{"type":"string","description":"BRANCH setting, not a user field: upserts the config `currency` of the user's location_id for everyone at that branch (\"\" clears an existing row)."},"orderhistory_print_receipt":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"show_receipt_screen":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"is_brand_groups":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"is_extras_enble":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"display_modifier_titles":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"modifier_sort_min_required_desc":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"print_only_save_kds_orders":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"print_note_on_receipt":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"order_type":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"open_dine_in":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"hide_cart_pay_button":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"tip_amount":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"print_void_orders":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"has_void_order_permission":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"hide_clear_checkout":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"kds_instruction":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"pay_default_usd":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"rpt_business_logo":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"rpt_order_type":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"rpt_is_modified":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"rpt_biller_info":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"rpt_total_before_vat":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"rpt_vat_info":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"rpt_net_total":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"rpt_exchange_rate":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"rpt_dine_guest_count":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"rpt_paid_at":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"esc_command":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"qr_code_on_print_receipt":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"delivery_detail_enable":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"is_attributed_receipt":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"exchange_format":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"is_channels_enable":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"is_cds_enable":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"external_order_print_receipt":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"text_receipt":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"each_category_kds_print":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"user_product_sales_counter":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"is_change_payment_method_enabled":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"skip_zero_quantity_items":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"skip_zero_amount_items":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"settle_wallet_topup":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"print_deleted_item_on_receipt":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"block_print_until_order_open":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"draft_order":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"open_price_pincode":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"is_transfer_enabled":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"sms_invoice":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"offline_order_sync":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"is_multi_location_allowed":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"is_qr_payment":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"is_kds_expeditor":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"unify_brands":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"dinein_serving_role":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"ingredient_stay_standalone":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"reservation_with_dine_in":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"pay_zero_amount":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"receipt_translation":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"product_translation":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"category_translation":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"print_invoice_translation":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"print_kds_translation":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"splitefire":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"allegy_item":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"one_table_one_order":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"is_socket_server":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"is_client":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"web_local_network_printer":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"is_shift_management":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"shift_transactions":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"eod_shift_report":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"shift_z_report":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"view_estimated_cash":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"external_order_ready":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"acknowledge_dine_table_edit":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"lock_screen":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"is_soldout":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."},"force_crm_user":{"type":"number","description":"0 | 1 (1, \"1\" or true → 1, anything else → 0)."}},"required":["id","username","email","location_id","allow_locations","modules","ios_modules","permissions"]},"module":"users","safety":"write"},{"name":"assign_user_role","description":"POST /api/users/assign-role. Why: Change only a staff user's role (users.role_id) without resending the whole record — the lighter alternative to update_user. role_id from list_roles, user_id from list_users. The role's permissions decide which dashboard pages the user can open. Unlike update_user it changes role text and is_admin only for roles whose name contains \"admin\"; moving an admin to a non-admin role leaves is_admin 1 — use update_user with role_id for that. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"assign_user_role\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/users/assign-role","why":"Change only a staff user's role (users.role_id) without resending the whole record — the lighter alternative to update_user. role_id from list_roles, user_id from list_users. The role's permissions decide which dashboard pages the user can open.","response":{"description":"200 → { success: true, message: \"User role assigned successfully.\" }. 400 { success: false, message: \"Missing required fields: user_id or role_id.\" }; 404 { success: false, message: \"User not found.\" } (no user with that id); 500 { success: false, message: \"Failed to assign user role.\" }. Role name contains \"admin\" (case-insensitive) → the server also sets role = that name lower-cased, is_admin = 1 and shift_transactions, eod_shift_report, shift_z_report, view_estimated_cash, external_order_ready, force_crm_user = 1; any other role → only role_id changes.","fields":{"success":"boolean. true when a user row was updated.","message":"string. Result text (see description)."},"example_request":{"user_id":4789,"role_id":182},"example":{"success":true,"message":"User role assigned successfully."},"master_rules":["The dashboard assigns roles through the user edit form (update_user role_id). Its old Assign Role page (/dashboard/users/<id>/assign-role) sends exactly { user_id, role_id } but cannot load a user any more (it reads `data` from list_users / get_user, which return bare arrays), so this route has no working dashboard caller.","Use it to switch between roles of the same kind (staff → other non-admin role, or → an admin role). To demote an admin to a non-admin role use update_user with the full record and the new role_id (that recomputes is_admin); this route would leave is_admin 1 and role \"admin\".","Check the result with list_users (role_id, role, is_admin) and the role's permissions with get_role."]},"parameters":{"type":"object","properties":{"user_id":{"type":"string","description":"User id (list_users [].id)."},"role_id":{"type":"string","description":"Role id (list_roles [].id)."}},"required":["user_id","role_id"]},"module":"users","safety":"write"},{"name":"list_pos_crm_users","description":"GET /api/v2/crm/users. Why: The customer list the POS cashier picks from to attach a customer to an order — exactly what web-pos loads (page_limit 500, order_by ASC) and caches. Each row carries what checkout needs: id (→ crm_user_id on the order), name, phone, delivery area, the wallet balance and the loyalty program. Customers are tenant-wide (every branch sees the same list). Use it to find/attach a customer; use list_crm_users_details / get_crm_user_details for analytics (visits, segment, wallet history). Paginated: loop current_page until you have total_records rows. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_pos_crm_users\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/v2/crm/users","why":"The customer list the POS cashier picks from to attach a customer to an order — exactly what web-pos loads (page_limit 500, order_by ASC) and caches. Each row carries what checkout needs: id (→ crm_user_id on the order), name, phone, delivery area, the wallet balance and the loyalty program. Customers are tenant-wide (every branch sees the same list). Use it to find/attach a customer; use list_crm_users_details / get_crm_user_details for analytics (visits, segment, wallet history). Paginated: loop current_page until you have total_records rows.","response":{"description":"{ status, message, data: { total_records, records_per_page, current_page, users[] } }. Tenant-wide list (no branch filter), ordered by id. ALWAYS check status: on a server error it is status:false with data: [] (an array, not the object).","fields":{"status":"boolean. true = success (also when nothing matched). false = server error / invalid page_limit.","message":"string. \"Users found\" | \"No Results Found\" | error text.","data.total_records":"int. Customers matching q across all pages. Pages = ceil(total_records / records_per_page).","data.records_per_page":"int. Page size used.","data.current_page":"int. Page returned.","data.users":"array. This page of customers. [] = none.","data.users[].id":"int. THE IDENTITY. Send it as crm_user_id on the order (sync_invoice); crmUserId for get_loyalty_card; crm_user_id for validate_coupon and validate_loyalty_burn.","data.users[].first_name":"string. First name. web-pos shows trim(first_name + \" \" + last_name) (\"Unnamed User\" if empty) and sends it as crm_user_name.","data.users[].last_name":"string | null. Last name (part of the display name above).","data.users[].mobile_number":"string | null. Phone as typed. Shown on the customer card and searched client-side. web-pos does NOT put it on the order (phone_number \"\"): the server copies it into the order's phone_number when that is empty.","data.users[].email":"string | null. Email. Shown and searched client-side. NOT sent on the order.","data.users[].dob":"string | null. Date of birth as stored (\"YYYY-MM-DD\" or \"MM-DD-YYYY\"; \"10-01-1961\" = placeholder from a POS create without dob). Shown in the detail sheet only.","data.users[].customer_id":"int. NOT USED — tenant id.","data.users[].delivery_area":"string | null. Delivery area / address. Shown in the detail sheet. NOT sent by web-pos: the server copies it into the order's crm_delivery_area when that is empty. \" \" = none.","data.users[].city":"string | null. City. Shown in the detail sheet only. \" \" = none.","data.users[].country":"string | null. Country. Shown in the detail sheet only. \" \" = none.","data.users[].is_push_notification_enable":"0 | 1. Shown as a \"Push on\" badge. NOT used at checkout.","data.users[].preferred_currency":"string | null. NOT USED at checkout — edit form only.","data.users[].latitude":"number. NOT USED at checkout — edit form only (0 = not set).","data.users[].longitude":"number. NOT USED at checkout — edit form only (0 = not set).","data.users[].company_name":"string | null. Company (B2B). Shown in the detail sheet.","data.users[].thumbnail":"string. FULL image URL, or \"\" = no image (show initials). Display only.","data.users[].is_mobile_number_verified":"0 | 1. Shown as a Verified / Unverified badge. NOT used at checkout.","data.users[].gender":"\"male\" | \"female\" | null. Shown in the detail sheet only.","data.users[].created_at_tz":"int. NOT USED — creation time as unix seconds of the local (tenant time zone) clock, not a true UTC epoch.","data.users[].number_of_purchases":"number. Orders so far (completed + open). Shown as \"Purchases\". Server-maintained.","data.users[].average_basket_size":"number. Average paid per order. Shown as \"Avg basket\". Server-maintained.","data.users[].total_spend":"number. Total paid so far. Shown as \"Total spend\". Server-maintained.","data.users[].earn_points":"number. Loyalty points balance on the customer's loyalty card (falls back to the legacy crm_users.earn_points when there is no card). Shown as \"Earned\". Before burning points read the live balance with get_loyalty_card.","data.users[].trn_number":"string | null. Tax registration number (B2B). Shown in the detail sheet.","data.users[].loyalty_program_name":"string. Active loyalty program of the customer's card; \"\" = no active program (no loyalty for this customer).","data.users[].redeem_points":"number. PROGRAM RATE, not a customer balance: redeem_points points are worth redeem_amount money. 0 = no program. (web-pos mislabels it \"Redeemable points\" / \"Redeemed\".)","data.users[].redeem_amount":"number. PROGRAM RATE: money value of redeem_points points. 0 = no program.","data.users[].wallet_id":"int | null. Active in-house wallet id; null = no active wallet yet (one is opened on the first wallet payment). NOT USED by web-pos.","data.users[].wallet_current_balance":"number. Balance of the customer's in-house credit wallet (house account): every wallet-paid order ADDS its amount, a settlement subtracts it. 0 when no wallet. web-pos copies it onto the cart/order as wallet_current_balance and shows it; it does NOT limit wallet payments.","data.users[].wallet_currency_code":"string | null. Wallet currency (often null). Display only.","data.users[].wallet_status":"\"active\" | null. Only active wallets are returned; null = no active wallet."},"master_rules":["Loading (web-pos): one call with page_limit=500, current_page=1, order_by=ASC, cached on the device and refreshed by the Reload button or after creating a customer. With more than 500 customers web-pos never sees the rest — you must page through total_records.","Searching (web-pos): client-side, case-insensitive substring over the display name (first + \" \" + last), mobile_number and email of the loaded list. The server q only matches first_name or last_name, so to find a customer by phone or email load the pages and filter them yourself.","Attach a customer to the order: crm_user_id = id, crm_user_name = trim(first_name + \" \" + last_name), wallet_current_balance = wallet_current_balance (web-pos stamps these on the order and on every cart line). Detach = crm_user_id 0, crm_user_name \"\", wallet_current_balance 0. Attaching, changing or detaching a customer drops any applied loyalty redemption.","On sync_invoice the server re-reads the customer by crm_user_id: an unknown id is reset to 0; on a new order crm_user_name is rebuilt from first_name + last_name; phone_number is filled from mobile_number and crm_delivery_area from delivery_area when you leave them empty. web-pos sends phone_number \"\" and no crm_delivery_area — do the same unless the customer gives a different phone/address for this order.","Wallet: a tender whose payment method type (or name) is \"wallet\" (list_selling_payment_methods) requires crm_user_id > 0 — web-pos blocks checkout with \"Select a CRM user before paying with wallet.\" On a PAID order with a wallet tender > 0 and a customer, send is_wallet_used = 1. The server then charges the WHOLE payable to the wallet (even in a split payment) and wallet_current_balance goes UP. There is no balance/limit check.","Loyalty: only when the tenant's loyalty module is on AND crm_user_id > 0. Call get_loyalty_card with crmUserId = id for the card (points or stamps). Point programs: validate_loyalty_burn before applying points, then put the burn fields on the order; stamp programs redeem when current_stamps >= stamps_required. Earned points are computed by the server when the paid order syncs (send earn_points 0).","Coupons: pass crm_user_id to validate_coupon so per-customer limits are enforced.","Branches: the list is tenant-wide — every branch of the master tenant shares the same customers; there is no branch parameter and none is needed.","The same ids appear on the dashboard: list_crm_users / get_crm_user (profile), list_crm_users_details / get_crm_user_details (visits, segment, churn, wallet history).","Remove duplicate ids: a customer with several loyalty cards is returned once per card (total_records counts unique customers)."],"example":{"status":true,"message":"Users found","data":{"total_records":2,"records_per_page":500,"current_page":1,"users":[{"id":63821,"first_name":"Test","last_name":"Customer","mobile_number":"+971500000000","email":"test@example.com","dob":"1990-01-01","customer_id":437,"delivery_area":"Test Area","city":"Test City","country":"Pakistan","is_push_notification_enable":0,"preferred_currency":"USD","latitude":0,"longitude":0,"company_name":"Test Company","thumbnail":"","is_mobile_number_verified":0,"gender":"male","created_at_tz":1782320919,"number_of_purchases":2,"average_basket_size":11,"earn_points":3,"total_spend":22,"trn_number":"100000000000003","loyalty_program_name":"Loyalty 1","redeem_points":10,"redeem_amount":1,"wallet_id":35,"wallet_current_balance":80,"wallet_currency_code":null,"wallet_status":"active"},{"id":64887,"first_name":"Second","last_name":"Customer","mobile_number":"+971500000001","email":"second@example.com","dob":null,"customer_id":437,"delivery_area":null,"city":null,"country":null,"is_push_notification_enable":0,"preferred_currency":null,"latitude":0,"longitude":0,"company_name":null,"thumbnail":"","is_mobile_number_verified":0,"gender":null,"created_at_tz":1782747497,"number_of_purchases":0,"average_basket_size":0,"earn_points":0,"total_spend":0,"trn_number":null,"loyalty_program_name":"Loyalty 1","redeem_points":10,"redeem_amount":1,"wallet_id":null,"wallet_current_balance":0,"wallet_currency_code":null,"wallet_status":null}]}}},"parameters":{"type":"object","properties":{"page_limit":{"type":"number","description":"Optional."},"current_page":{"type":"number","description":"Optional."},"order_by":{"type":"string","enum":["ASC","DESC"],"description":"Optional."},"q":{"type":"string","description":"Optional."}},"required":[]},"module":"crm_users","safety":"read"},{"name":"create_pos_crm_user","description":"POST /api/v2/crm/users. Why: Create a customer from the POS — the web-pos \"Add user\" form. Only first_name is required; phone and email must be unique in the tenant. Returns the new id (user_id) to use as crm_user_id on the order. The customer is tenant-wide (visible at every branch and on the dashboard list_crm_users). Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_pos_crm_user\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/v2/crm/users","why":"Create a customer from the POS — the web-pos \"Add user\" form. Only first_name is required; phone and email must be unique in the tenant. Returns the new id (user_id) to use as crm_user_id on the order. The customer is tenant-wide (visible at every branch and on the dashboard list_crm_users).","response":{"description":"HTTP 200 in every handled case — check status. Success: { status: true, message, user_id }. Failure: { status: false, message } (validation or duplicate).","fields":{"status":"boolean. true = created. false = rejected (see message); nothing was saved.","message":"string. \"User has been created successfully.\" | \"email already exist\" | \"mobile number already exist\" | \"first name or last name cannot be blank\" | a validation message (e.g. \"please enter a valid phone number\").","user_id":"int. Present on success only. New customer id → crm_user_id on the order, crmUserId for get_loyalty_card."},"master_rules":["web-pos create form: first_name (required), last_name, mobile_number, email (sent only when not blank), gender (default \"male\"), dob (the date picker value re-ordered to MM-DD-YYYY), city, country, delivery_area, preferred_currency (the POS currency), is_mobile_number_verified, is_push_notification_enable (form default on). It also sends brand_id, which the server ignores.","Before creating, search list_pos_crm_users for the phone/email: a duplicate is rejected with \"email already exist\" / \"mobile number already exist\" — attach the existing customer instead.","Omit fields you don't know instead of sending \"\": empty last_name or email fail validation. Omitted dob and gender are stored as the placeholders \"10-01-1961\" and \"male\".","After success web-pos reloads the customer list; the new customer is NOT attached automatically — attach it (crm_user_id = user_id, crm_user_name = first_name + \" \" + last_name) to the order yourself.","A new customer has no loyalty card and no wallet yet: get_loyalty_card may return nothing; the wallet opens on the first wallet payment."],"example":{"status":true,"message":"User has been created successfully.","user_id":64900}},"parameters":{"type":"object","properties":{"first_name":{"type":"string","description":"REQUIRED."},"last_name":{"type":"string","description":"Optional."},"mobile_number":{"type":"string","description":"Optional."},"email":{"type":"string","description":"Optional."},"dob":{"type":"string","description":"Optional."},"gender":{"type":"string","enum":["male","female"],"description":"Optional."},"delivery_area":{"type":"string","description":"Optional."},"city":{"type":"string","description":"Optional."},"country":{"type":"string","description":"Optional."},"preferred_currency":{"type":"string","description":"Optional."},"is_push_notification_enable":{"type":"string","enum":["0","1"],"description":"Optional."},"is_mobile_number_verified":{"type":"string","enum":["0","1"],"description":"Optional."},"latitude":{"type":"number","description":"Optional."},"longitude":{"type":"number","description":"Optional."},"trn_number":{"type":"string","description":"Optional."},"company_name":{"type":"string","description":"Optional."},"thumbnail":{"type":"string","description":"Optional."},"earn_points":{"type":"number","description":"Optional."}},"required":["first_name"]},"module":"crm_users","safety":"write"},{"name":"update_pos_crm_user","description":"POST /api/v2/crm/users/:id. Why: Edit a customer from the POS — the web-pos \"Edit\" form (fix a phone number, add a delivery area before a delivery order). Partial update: only the fields you send with a non-empty value change. Tenant-checked by id. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_pos_crm_user\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/v2/crm/users/:id","why":"Edit a customer from the POS — the web-pos \"Edit\" form (fix a phone number, add a delivery area before a delivery order). Partial update: only the fields you send with a non-empty value change. Tenant-checked by id.","response":{"description":"HTTP 200 in every handled case — check status AND message. { status, message }. No customer data is returned: re-read with list_pos_crm_users (or get_crm_user) to see the result.","fields":{"status":"boolean. false = rejected by validation or a duplicate phone/email. true does NOT prove a row changed — read message.","message":"string. \"User has been updated successfully\" | \"No User found against ID <id>\" (status true, nothing changed: id not in your tenant) | \"Email already exist\" | \"Mobile number already exist\" | a validation message."},"master_rules":["Partial merge: every field that is omitted, \"\", 0 or false keeps its stored value — you cannot clear a field here (use update_crm_user, which replaces the whole profile).","SIDE EFFECT: every update resets the legacy crm_users.earn_points column to 0. The loyalty card balance (get_loyalty_card) is not touched, but a customer WITHOUT a card loses the points list_pos_crm_users shows as earn_points.","Send only the fields you change. web-pos sends its whole edit form (including thumbnail URL and the stats number_of_purchases, total_spend, average_basket_size, earn_points, which the server ignores) — do NOT copy that: a thumbnail URL breaks the photo.","Flags are strings: is_push_notification_enable / is_mobile_number_verified \"1\" or \"0\".","Changing mobile_number or delivery_area only affects future orders: the server copies them onto an order at sync time when the order leaves phone_number / crm_delivery_area empty.","Name changes are not pushed to existing orders' crm_user_name; after editing an attached customer, re-attach it so the cart's crm_user_name matches."],"example":{"status":true,"message":"User has been updated successfully"}},"parameters":{"type":"object","properties":{"id":{"type":"string","description":"Customer id (list_pos_crm_users data.users[].id)."},"first_name":{"type":"string","description":"Optional."},"last_name":{"type":"string","description":"Optional."},"mobile_number":{"type":"string","description":"Optional."},"email":{"type":"string","description":"Optional."},"dob":{"type":"string","description":"Optional."},"gender":{"type":"string","enum":["male","female"],"description":"Optional."},"delivery_area":{"type":"string","description":"Optional."},"city":{"type":"string","description":"Optional."},"country":{"type":"string","description":"Optional."},"preferred_currency":{"type":"string","description":"Optional."},"is_push_notification_enable":{"type":"string","enum":["0","1"],"description":"Optional."},"is_mobile_number_verified":{"type":"string","enum":["0","1"],"description":"Optional."},"latitude":{"type":"number","description":"Optional."},"longitude":{"type":"number","description":"Optional."},"trn_number":{"type":"string","description":"Optional."},"company_name":{"type":"string","description":"Optional."},"thumbnail":{"type":"string","description":"Optional."}},"required":["id"]},"module":"crm_users","safety":"write"},{"name":"list_crm_users","description":"GET /api/crm-users. Why: Dashboard customer (CRM) list — one slim row per customer of the tenant (name, phone, email, company, TRN). Same ids as list_pos_crm_users and the crm_user_id on orders. To attach a customer at the POS use list_pos_crm_users (it has wallet + loyalty); for visits/segments use list_crm_users_details. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_crm_users\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/crm-users","why":"Dashboard customer (CRM) list — one slim row per customer of the tenant (name, phone, email, company, TRN). Same ids as list_pos_crm_users and the crm_user_id on orders. To attach a customer at the POS use list_pos_crm_users (it has wallet + loyalty); for visits/segments use list_crm_users_details.","response":{"description":"{ data: [...], total_records } — no status envelope. Tenant-wide (all branches). Rows are not explicitly sorted (in practice by id).","fields":{"data":"array. This page of customers. [] = none.","data[].id":"int. CRM customer id = crm_user_id on orders (sync_invoice), crmUserId for get_loyalty_card, crm_user_id for validate_coupon / validate_loyalty_burn.","data[].first_name":"string. First name (always set; required on create).","data[].last_name":"string | null. Last name. Display name = trim(first_name + \" \" + last_name).","data[].company_name":"string | null. Customer's company (B2B invoices).","data[].mobile_number":"string | null. Phone as typed (e.g. \"+971500000000\"). Unique per tenant (create rejects a duplicate).","data[].earn_points":"number. LEGACY crm_users.earn_points column (often 0 even when the loyalty card has points). For the real points balance call get_loyalty_card.","data[].email":"string | null. Email. Unique per tenant (create rejects a duplicate).","data[].gender":"\"male\" | \"female\" | null. POS create defaults to \"male\" when not sent.","data[].trn_number":"string | null. Customer's tax registration number (B2B invoices).","data[].thumbnail":"string | null. RAW stored image path relative to the server (e.g. \"/uploads/users/x.jpeg\"); \" \" or null = no image.","total_records":"int. Customers matching k across all pages."},"example":{"data":[{"id":63821,"first_name":"Test","last_name":"Customer","company_name":"Test Company","mobile_number":"+971500000000","earn_points":0,"email":"test@example.com","gender":"male","trn_number":"100000000000003","thumbnail":" "},{"id":64887,"first_name":"Second","last_name":"Customer","company_name":null,"mobile_number":"+971500000001","earn_points":0,"email":"second@example.com","gender":null,"trn_number":null,"thumbnail":null}],"total_records":2}},"parameters":{"type":"object","properties":{"current_page":{"type":"number","description":"Optional."},"page_limit":{"type":"number","description":"Optional."},"k":{"type":"string","description":"Optional."}},"required":[]},"module":"crm_users","safety":"read"},{"name":"list_crm_users_details","description":"GET /api/crm-users/details. Why: Dashboard CRM analytics list: every customer column plus an all-time order summary, RFM segment and churn risk per customer, with tenant-wide KPIs. Filters by order date window, branch, channel, spend, visit frequency, inactivity, segment. Sorted by last visit (most recent first, never-ordered last). Use it for marketing/retention questions; use list_pos_crm_users to attach a customer to an order. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_crm_users_details\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/crm-users/details","why":"Dashboard CRM analytics list: every customer column plus an all-time order summary, RFM segment and churn risk per customer, with tenant-wide KPIs. Filters by order date window, branch, channel, spend, visit frequency, inactivity, segment. Sorted by last visit (most recent first, never-ordered last). Use it for marketing/retention questions; use list_pos_crm_users to attach a customer to an order.","response":{"description":"{ status, message, data[], total_records, kpis }. Orders counted = status completed and not refunded, whole tenant. HTTP 400 { status:false, message } when a segment/churn/preferred_time filter would scan > 25 000 customers; HTTP 500 { status:false, message } on error.","fields":{"status":"boolean. true = success.","message":"string. \"CRM users with detail fetched successfully\" or the error.","data":"array. This page of customers. [] = none.","data[].id":"int. CRM customer id = crm_user_id on orders (sync_invoice), crmUserId for get_loyalty_card, crm_user_id for validate_coupon / validate_loyalty_burn.","data[].first_name":"string. First name (always set; required on create).","data[].last_name":"string | null. Last name. Display name = trim(first_name + \" \" + last_name).","data[].mobile_number":"string | null. Phone as typed (e.g. \"+971500000000\"). Unique per tenant (create rejects a duplicate).","data[].email":"string | null. Email. Unique per tenant (create rejects a duplicate).","data[].dob":"string | null. Date of birth, free text as stored (\"YYYY-MM-DD\" or \"MM-DD-YYYY\"). POS-created customers without a dob get the placeholder \"10-01-1961\".","data[].customer_id":"int. Tenant id. NOT USED.","data[].delivery_area":"string | null. Delivery area / address line. Copied by the server into the order's crm_delivery_area when the order leaves it empty. \" \" = none (POS create placeholder).","data[].city":"string | null. City. \" \" = none (POS create placeholder).","data[].country":"string | null. Country. \" \" = none (POS create placeholder).","data[].created_at":"string. ISO datetime (UTC) the customer was created.","data[].gender":"\"male\" | \"female\" | null. POS create defaults to \"male\" when not sent.","data[].preferred_currency":"string | null. Currency code the customer prefers (e.g. \"AED\"). Informational.","data[].latitude":"number. Delivery latitude, 0 = not set.","data[].longitude":"number. Delivery longitude, 0 = not set.","data[].thumbnail":"string | null. RAW stored image path relative to the server (e.g. \"/uploads/users/x.jpeg\"); \" \" or null = no image.","data[].is_mobile_number_verified":"0 | 1. 1 = phone verified (e.g. via the customer app).","data[].is_push_notification_enable":"0 | 1. 1 = customer accepts push notifications.","data[].number_of_purchases":"number. Server-maintained: count of this customer's completed + open orders (recomputed after each order sync).","data[].average_basket_size":"number. Server-maintained: average paid amount of those orders.","data[].total_spend":"number. Server-maintained: sum of paid amounts of those orders.","data[].earn_points":"number. LEGACY crm_users.earn_points column (often 0 even when the loyalty card has points). For the real points balance call get_loyalty_card.","data[].trn_number":"string | null. Customer's tax registration number (B2B invoices).","data[].company_name":"string | null. Customer's company (B2B invoices).","data[].summary.total_spend":"number. All-time sum of payable of completed, non-refunded orders.","data[].summary.visit_count":"int. All-time count of those orders.","data[].summary.last_visit":"string | null. ISO datetime of the latest such order; null = never ordered.","data[].summary.avg_order_value":"number. total_spend / visit_count (0 when no visits).","data[].segment":"string | null. \"New\" (0 visits), \"One-timer\" (1), else RFM: \"VIP\", \"Loyal\", \"Regular\", \"At Risk\", \"Needs Attention\", \"Lost\"; null = unclassified.","data[].churn_risk":"\"Low\" | \"Medium\" | \"High\" | null. Days since last visit vs the customer's usual gap between visits; null = never ordered.","total_records":"int. Customers matching the filters across all pages.","kpis.total_customers":"int. All customers of the tenant (filters ignored).","kpis.total_revenue":"number. Sum of payable of ALL completed, non-refunded orders of the tenant (with or without a customer).","kpis.avg_order_value":"number. total_revenue / number of those orders.","kpis.vip_customers":"int. Customers in segment VIP (filters ignored).","kpis.high_churn_risk_customers":"int. Customers with churn_risk High (filters ignored).","kpis.avg_visits_per_month":"number. Average visits per month since first visit, over customers who ordered."},"example":{"status":true,"message":"CRM users with detail fetched successfully","data":[{"id":63821,"first_name":"Test","last_name":"Customer","mobile_number":"+971500000000","email":"test@example.com","dob":"1990-01-01","customer_id":437,"delivery_area":"Test Area","city":"Test City","country":"Pakistan","created_at":"2026-06-24T12:08:39.000Z","gender":"male","preferred_currency":"USD","latitude":0,"longitude":0,"thumbnail":" ","is_mobile_number_verified":0,"is_push_notification_enable":0,"number_of_purchases":2,"average_basket_size":11,"total_spend":22,"earn_points":0,"trn_number":"100000000000003","company_name":"Test Company","summary":{"total_spend":22,"visit_count":2,"last_visit":"2026-08-20T11:07:42.000Z","avg_order_value":11},"segment":"Needs Attention","churn_risk":"Low"},{"id":64887,"first_name":"Second","last_name":"Customer","mobile_number":"+971500000001","email":"second@example.com","dob":null,"customer_id":437,"delivery_area":null,"city":null,"country":null,"created_at":"2026-06-29T10:38:17.000Z","gender":null,"preferred_currency":null,"latitude":0,"longitude":0,"thumbnail":null,"is_mobile_number_verified":0,"is_push_notification_enable":0,"number_of_purchases":0,"average_basket_size":0,"total_spend":0,"earn_points":0,"trn_number":null,"company_name":null,"summary":{"total_spend":0,"visit_count":0,"last_visit":null,"avg_order_value":0},"segment":"New","churn_risk":null}],"total_records":2,"kpis":{"total_customers":2,"total_revenue":893,"avg_order_value":49.61,"vip_customers":0,"high_churn_risk_customers":0,"avg_visits_per_month":1.7}}},"parameters":{"type":"object","properties":{"k":{"type":"string","description":"Optional."},"current_page":{"type":"number","description":"Optional."},"page_limit":{"type":"number","description":"Optional."},"from_date":{"type":"string","description":"Optional."},"to_date":{"type":"string","description":"Optional."},"location_id":{"type":"string","description":"Branch id."},"channel_id":{"type":"number","description":"Optional."},"spend_range":{"type":"string","description":"Optional."},"visit_frequency":{"type":"string","enum":["Low","Medium","High"],"description":"Optional."},"inactive_days":{"type":"number","description":"Optional."},"segment":{"type":"string","enum":["VIP","Loyal","Regular","At Risk","Needs Attention","Lost","New","One-timer"],"description":"Optional."},"churn_risk":{"type":"string","enum":["Low","Medium","High"],"description":"Optional."},"preferred_time":{"type":"string","enum":["breakfast","brunch","lunch","evening","dinner"],"description":"Optional."}},"required":[]},"module":"crm_users","safety":"read"},{"name":"get_crm_user","description":"GET /api/crm-users/:id. Why: One customer's stored profile (every contact/address field + server-maintained purchase stats). Tenant-checked. For visits, segment and wallet history use get_crm_user_details; for wallet balance and loyalty in POS terms use list_pos_crm_users. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_crm_user\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/crm-users/:id","why":"One customer's stored profile (every contact/address field + server-maintained purchase stats). Tenant-checked. For visits, segment and wallet history use get_crm_user_details; for wallet balance and loyalty in POS terms use list_pos_crm_users.","response":{"description":"A BARE JSON ARRAY with one row, or [] when the id is not a customer of your tenant.","fields":{"[]":"array. [customer] or [] = not found in this tenant.","[].id":"int. CRM customer id = crm_user_id on orders (sync_invoice), crmUserId for get_loyalty_card, crm_user_id for validate_coupon / validate_loyalty_burn.","[].first_name":"string. First name (always set; required on create).","[].last_name":"string | null. Last name. Display name = trim(first_name + \" \" + last_name).","[].company_name":"string | null. Customer's company (B2B invoices).","[].email":"string | null. Email. Unique per tenant (create rejects a duplicate).","[].mobile_number":"string | null. Phone as typed (e.g. \"+971500000000\"). Unique per tenant (create rejects a duplicate).","[].dob":"string | null. Date of birth, free text as stored (\"YYYY-MM-DD\" or \"MM-DD-YYYY\"). POS-created customers without a dob get the placeholder \"10-01-1961\".","[].earn_points":"number. LEGACY crm_users.earn_points column (often 0 even when the loyalty card has points). For the real points balance call get_loyalty_card.","[].customer_id":"int. Tenant id. NOT USED.","[].delivery_area":"string | null. Delivery area / address line. Copied by the server into the order's crm_delivery_area when the order leaves it empty. \" \" = none (POS create placeholder).","[].city":"string | null. City. \" \" = none (POS create placeholder).","[].is_push_notification_enable":"0 | 1. 1 = customer accepts push notifications.","[].preferred_currency":"string | null. Currency code the customer prefers (e.g. \"AED\"). Informational.","[].trn_number":"string | null. Customer's tax registration number (B2B invoices).","[].latitude":"number. Delivery latitude, 0 = not set.","[].longitude":"number. Delivery longitude, 0 = not set.","[].thumbnail":"string | null. RAW stored image path relative to the server (e.g. \"/uploads/users/x.jpeg\"); \" \" or null = no image.","[].is_mobile_number_verified":"0 | 1. 1 = phone verified (e.g. via the customer app).","[].gender":"\"male\" | \"female\" | null. POS create defaults to \"male\" when not sent.","[].country":"string | null. Country. \" \" = none (POS create placeholder).","[].created_at":"string. ISO datetime (UTC) the customer was created.","[].number_of_purchases":"number. Server-maintained: count of this customer's completed + open orders (recomputed after each order sync).","[].average_basket_size":"number. Server-maintained: average paid amount of those orders.","[].total_spend":"number. Server-maintained: sum of paid amounts of those orders."},"example":[{"id":63821,"first_name":"Test","last_name":"Customer","company_name":"Test Company","email":"test@example.com","mobile_number":"+971500000000","dob":"1990-01-01","earn_points":0,"customer_id":437,"delivery_area":"Test Area","city":"Test City","is_push_notification_enable":0,"preferred_currency":"USD","trn_number":"100000000000003","latitude":0,"longitude":0,"thumbnail":" ","is_mobile_number_verified":0,"gender":"male","country":"Pakistan","created_at":"2026-06-24T12:08:39.000Z","number_of_purchases":2,"average_basket_size":11,"total_spend":22}]},"parameters":{"type":"object","properties":{"id":{"type":"string","description":"Customer id (crm_user_id)."}},"required":["id"]},"module":"crm_users","safety":"read"},{"name":"get_crm_user_details","description":"GET /api/crm-users/:id/details. Why: Customer intelligence for one customer: profile, all-time order summary, RFM segment, churn risk, value rank, visit patterns (time, days, order types), menu preferences (top items), spending trend, discount usage and the in-house wallet with its last 50 transactions. Tenant-checked. Use it to answer \"who is this customer / how are they doing\". Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_crm_user_details\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/crm-users/:id/details","why":"Customer intelligence for one customer: profile, all-time order summary, RFM segment, churn risk, value rank, visit patterns (time, days, order types), menu preferences (top items), spending trend, discount usage and the in-house wallet with its last 50 transactions. Tenant-checked. Use it to answer \"who is this customer / how are they doing\".","response":{"description":"{ status, message, data }. Orders counted = completed and not refunded. HTTP 404 { status:false, message:\"CRM user not found\" } when the id is not in your tenant; 500 { status:false, message } on error.","fields":{"status":"boolean. true = found.","message":"string. \"CRM user detail fetched successfully\" or the error.","data.user.id":"int. CRM customer id = crm_user_id on orders (sync_invoice), crmUserId for get_loyalty_card, crm_user_id for validate_coupon / validate_loyalty_burn.","data.user.first_name":"string. First name (always set; required on create).","data.user.last_name":"string | null. Last name. Display name = trim(first_name + \" \" + last_name).","data.user.mobile_number":"string | null. Phone as typed (e.g. \"+971500000000\"). Unique per tenant (create rejects a duplicate).","data.user.email":"string | null. Email. Unique per tenant (create rejects a duplicate).","data.user.dob":"string | null. Date of birth, free text as stored (\"YYYY-MM-DD\" or \"MM-DD-YYYY\"). POS-created customers without a dob get the placeholder \"10-01-1961\".","data.user.customer_id":"int. Tenant id. NOT USED.","data.user.delivery_area":"string | null. Delivery area / address line. Copied by the server into the order's crm_delivery_area when the order leaves it empty. \" \" = none (POS create placeholder).","data.user.city":"string | null. City. \" \" = none (POS create placeholder).","data.user.country":"string | null. Country. \" \" = none (POS create placeholder).","data.user.created_at":"string. ISO datetime (UTC) the customer was created.","data.user.gender":"\"male\" | \"female\" | null. POS create defaults to \"male\" when not sent.","data.user.preferred_currency":"string | null. Currency code the customer prefers (e.g. \"AED\"). Informational.","data.user.latitude":"number. Delivery latitude, 0 = not set.","data.user.longitude":"number. Delivery longitude, 0 = not set.","data.user.thumbnail":"string | null. RAW stored image path relative to the server (e.g. \"/uploads/users/x.jpeg\"); \" \" or null = no image.","data.user.is_mobile_number_verified":"0 | 1. 1 = phone verified (e.g. via the customer app).","data.user.is_push_notification_enable":"0 | 1. 1 = customer accepts push notifications.","data.user.number_of_purchases":"number. Server-maintained: count of this customer's completed + open orders (recomputed after each order sync).","data.user.average_basket_size":"number. Server-maintained: average paid amount of those orders.","data.user.total_spend":"number. Server-maintained: sum of paid amounts of those orders.","data.user.earn_points":"number. LEGACY crm_users.earn_points column (often 0 even when the loyalty card has points). For the real points balance call get_loyalty_card.","data.user.trn_number":"string | null. Customer's tax registration number (B2B invoices).","data.user.company_name":"string | null. Customer's company (B2B invoices).","data.summary.total_spend":"number. All-time payable of completed, non-refunded orders.","data.summary.visit_count":"int. Number of those orders.","data.summary.last_visit":"string | null. ISO datetime of the latest order.","data.summary.avg_order_value":"number. total_spend / visit_count.","data.summary.visit_frequency":"number. Visits per month between first and last visit (min 1 month).","data.segment":"string | null. \"New\" | \"One-timer\" | \"VIP\" | \"Loyal\" | \"Regular\" | \"At Risk\" | \"Needs Attention\" | \"Lost\".","data.loyalty_tier":"null. Not implemented.","data.churn_risk":"\"Low\" | \"Medium\" | \"High\" | null (never ordered).","data.tags":"null. Not implemented.","data.notes":"null. Not implemented.","data.rfm_score.recency":"int 1–5. 5 = ordered very recently (90-day scale).","data.rfm_score.frequency":"int 1–5. ceil(visits / 5).","data.rfm_score.monetary":"int 1–5. ceil(total_spend / 1000).","data.rfm_score.rfm_segment":"string | null. Segment from R+F+M alone (ignores the New/One-timer rule).","data.value_rank":"\"High Value\" (top 10%) | \"Mid Value\" (top 50%) | \"Low Value\" | null. Rank by the stored crm_users.total_spend.","data.value_rank_percentile":"int | null. rank / total customers × 100 (lower = better).","data.lifetime_value":"number. Projected LTV = avg_order_value × visits per month × expected lifespan; falls back to total_spend.","data.visit_patterns.visit_frequency":"number. Same as summary.visit_frequency.","data.visit_patterns.preferred_time":"string | null. \"Breakfast\" | \"Lunch\" | \"Afternoon\" | \"Dinner\" | \"Late Night\" (server clock hours).","data.visit_patterns.first_visit":"string | null. ISO datetime of the first order.","data.visit_patterns.days_since_last_visit":"int | null. Days since the last order.","data.visit_patterns.avg_party_size":"number | null. Average dining_guests.","data.visit_patterns.table_preference":"string | null. Most used table id (\"0\" = no table).","data.visit_patterns.preferred_days":"string[] | null. Up to 2 most frequent weekdays.","data.visit_patterns.order_type_split":"object | null. { \"<order type>\": percent } over the customer's orders.","data.menu_preferences.favorite_category":"string | null. Most ordered product type (by qty).","data.menu_preferences.avg_items_per_order":"number | null. Item qty per order.","data.menu_preferences.beverage_food_ratio":"string | null. e.g. \"100% beverages\".","data.menu_preferences.top_ordered_items":"string[]. Up to 5 most ordered item names.","data.menu_preferences.dietary_preferences":"null. Not implemented.","data.spending.total_spend":"number. Same as summary.total_spend.","data.spending.average_order_value":"number. Same as summary.avg_order_value.","data.spending.lowest_order":"number | null. Smallest order payable.","data.spending.highest_order":"number | null. Largest order payable.","data.spending.lifetime_value":"number. Same as data.lifetime_value.","data.spending.avg_items_per_order":"number | null. Same as menu_preferences.avg_items_per_order.","data.spending.spending_trend":"\"Increasing\" | \"Decreasing\" | \"Stable\" | \"New\". Last 30 days vs the 30 days before.","data.spending.trend_percentage":"number. Change in %.","data.spending.trend_label":"string. e.g. \"+12.5%\", \"-100%\", \"New\".","data.promotions_discounts.discount_usage_rate":"int. % of orders with a discount.","data.promotions_discounts.discount_revenue_ratio":"int | null. Discount amount as % of revenue.","data.promotions_discounts.promo_responsiveness":"int. Always 0 (no campaign data yet).","data.wallet.current_balance":"number. In-house wallet (house account) balance — what wallet-paid orders added minus settlements; 0 = no wallet. Includes a suspended/closed wallet.","data.wallet.transactions":"array. Last 50 wallet transactions, newest first. [] = none.","data.wallet.transactions[].id":"int. Transaction id.","data.wallet.transactions[].wallet_id":"int. Wallet id.","data.wallet.transactions[].user_id":"int. Customer id.","data.wallet.transactions[].transaction_type":"\"credit\" | \"debit\" | \"adjustment\". credit = a wallet-paid order added to the house account (balance up); debit = a settlement of the account (balance down); adjustment = manual +/-.","data.wallet.transactions[].amount":"number. Transaction amount.","data.wallet.transactions[].balance_before":"number | null. Wallet balance before.","data.wallet.transactions[].balance_after":"number | null. Wallet balance after.","data.wallet.transactions[].reference_type":"string. \"invoice\" (wallet-paid order) or \"inhouse_credit\" (settlement).","data.wallet.transactions[].reference_id":"string. Order uuid for reference_type \"invoice\"; a generated uuid otherwise.","data.wallet.transactions[].performed_by_user_id":"int | null. Staff user who made it.","data.wallet.transactions[].source":"string. Origin, e.g. \"invoice-sync-v3\", \"crm-users/inhouse-credit\".","data.wallet.transactions[].description":"string | null. Free text (order credits read \"Wallet debit for invoice <uuid>\" despite being credits).","data.wallet.transactions[].customer_id":"int. Tenant id.","data.wallet.transactions[].location_id":"int | null. Branch where it happened.","data.wallet.transactions[].created_at":"string. ISO datetime.","data.wallet.transactions[].payment_id":"int | null. Payment method id used to settle (settlements only).","data.wallet.transactions[].payment_method_name":"string | null. Payment method name used to settle (settlements only)."},"example":{"status":true,"message":"CRM user detail fetched successfully","data":{"user":{"id":63821,"first_name":"Test","last_name":"Customer","mobile_number":"+971500000000","email":"test@example.com","dob":"1990-01-01","customer_id":437,"delivery_area":"Test Area","city":"Test City","country":"Pakistan","created_at":"2026-06-24T12:08:39.000Z","gender":"male","preferred_currency":"USD","latitude":0,"longitude":0,"thumbnail":" ","is_mobile_number_verified":0,"is_push_notification_enable":0,"number_of_purchases":2,"average_basket_size":11,"total_spend":22,"earn_points":0,"trn_number":"100000000000003","company_name":"Test Company"},"summary":{"total_spend":22,"visit_count":2,"last_visit":"2026-08-20T11:07:42.000Z","avg_order_value":11,"visit_frequency":2},"segment":"Needs Attention","loyalty_tier":null,"churn_risk":"Low","tags":null,"notes":null,"rfm_score":{"recency":4,"frequency":1,"monetary":1,"rfm_segment":"Needs Attention"},"value_rank":"Mid Value","value_rank_percentile":50,"lifetime_value":264,"visit_patterns":{"visit_frequency":2,"preferred_time":"Lunch","first_visit":"2026-08-20T11:07:42.000Z","days_since_last_visit":35,"avg_party_size":1,"table_preference":"0","preferred_days":["Thursday"],"order_type_split":{"Delivery":100}},"menu_preferences":{"favorite_category":"espresso-based drinks","avg_items_per_order":1,"beverage_food_ratio":"100% beverages","top_ordered_items":["QA min Flat White"],"dietary_preferences":null},"spending":{"total_spend":22,"average_order_value":11,"lowest_order":11,"highest_order":11,"lifetime_value":264,"avg_items_per_order":1,"spending_trend":"Decreasing","trend_percentage":-100,"trend_label":"-100%"},"promotions_discounts":{"discount_usage_rate":0,"discount_revenue_ratio":0,"promo_responsiveness":0},"wallet":{"current_balance":80,"transactions":[{"id":408,"wallet_id":35,"user_id":63821,"transaction_type":"credit","amount":60,"balance_before":20,"balance_after":80,"reference_type":"invoice","reference_id":"8D27D146-767C-4E22-9FB3-0611991DAB63","performed_by_user_id":4734,"source":"invoice-sync-v3","description":"Wallet debit for invoice 8D27D146-767C-4E22-9FB3-0611991DAB63","customer_id":437,"location_id":907,"created_at":"2026-06-29T10:14:55.000Z","payment_id":null,"payment_method_name":null}]}}}},"parameters":{"type":"object","properties":{"id":{"type":"string","description":"Customer id (crm_user_id)."}},"required":["id"]},"module":"crm_users","safety":"read"},{"name":"create_crm_user","description":"POST /api/crm-users. Why: Create a customer from the dashboard. Tenant-wide (every branch). Rejects a phone or email already used by another customer. Unlike create_pos_crm_user it stores no placeholders (missing dob/gender stay null). Returns user_id = crm_user_id. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_crm_user\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/crm-users","why":"Create a customer from the dashboard. Tenant-wide (every branch). Rejects a phone or email already used by another customer. Unlike create_pos_crm_user it stores no placeholders (missing dob/gender stay null). Returns user_id = crm_user_id.","response":{"description":"Success HTTP 200 { success: true, message, user_id }. HTTP 400 { success:false, message } on validation error or duplicate; HTTP 500 { success:false, message } on a DB error. Note the key is success, not status.","fields":{"success":"boolean. true = created.","message":"string. \"CRM User created successfully\" | \"A CRM user with this email or mobile number already exists.\" | a validation message.","user_id":"int. Present on success. New customer id (crm_user_id)."},"example":{"success":true,"message":"CRM User created successfully","user_id":64900}},"parameters":{"type":"object","properties":{"first_name":{"type":"string","description":"REQUIRED."},"last_name":{"type":"string","description":"Optional."},"company_name":{"type":"string","description":"Optional."},"email":{"type":"string","description":"Optional."},"mobile_number":{"type":"string","description":"Optional."},"dob":{"type":"string","description":"Optional."},"delivery_area":{"type":"string","description":"Optional."},"city":{"type":"string","description":"Optional."},"country":{"type":"string","description":"Optional."},"preferred_currency":{"type":"string","description":"Optional."},"latitude":{"type":"number","description":"Optional."},"longitude":{"type":"number","description":"Optional."},"gender":{"type":"string","enum":["male","female"],"description":"Optional."},"thumbnail":{"type":"string","description":"Optional."},"trn_number":{"type":"string","description":"Optional."}},"required":["first_name"]},"module":"crm_users","safety":"write"},{"name":"update_crm_user","description":"PUT /api/crm-users/:id. Why: Replace a customer's profile from the dashboard. FULL REPLACE: every field below that you omit is set to NULL — read get_crm_user first and send every value you want to keep. Use it to CLEAR a field (update_pos_crm_user cannot). Does not touch loyalty points, wallet, flags or purchase stats. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_crm_user\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/crm-users/:id","why":"Replace a customer's profile from the dashboard. FULL REPLACE: every field below that you omit is set to NULL — read get_crm_user first and send every value you want to keep. Use it to CLEAR a field (update_pos_crm_user cannot). Does not touch loyalty points, wallet, flags or purchase stats.","response":{"description":"HTTP 200 { success: true, message } — also when the id is not in your tenant (nothing changed; verify with get_crm_user). HTTP 400 { success:false, message } on validation error; 500 on a DB error.","fields":{"success":"boolean. true = request accepted (NOT proof a row changed).","message":"string. \"CRM User updated successfully\" or the validation/DB error."},"example":{"success":true,"message":"CRM User updated successfully"}},"parameters":{"type":"object","properties":{"id":{"type":"string","description":"Customer id (crm_user_id)."},"first_name":{"type":"string","description":"REQUIRED."},"last_name":{"type":"string","description":"Last name."},"company_name":{"type":"string","description":"Company name."},"email":{"type":"string","description":"Valid email."},"mobile_number":{"type":"string","description":"Phone (digits, optional leading +)."},"dob":{"type":"string","description":"Date of birth after 1960-01-01."},"delivery_area":{"type":"string","description":"Delivery area (letters, digits, spaces, commas, hyphens)."},"city":{"type":"string","description":"City."},"country":{"type":"string","description":"Country."},"preferred_currency":{"type":"string","description":"Currency code."},"latitude":{"type":"number","description":"Latitude."},"longitude":{"type":"number","description":"Longitude."},"gender":{"type":"string","enum":["male","female"],"description":"\"male\" or \"female\"."},"thumbnail":{"type":"string","description":"Image path as returned by get_crm_user."},"trn_number":{"type":"string","description":"Tax registration number."}},"required":["id","first_name"]},"module":"crm_users","safety":"write"},{"name":"list_roles","description":"GET /api/roles. Why: Roles of the tenant with the permissions each grants — the left list of the dashboard Roles & Permissions page and the Roles dropdown of the user edit form. Use it to pick role_id for update_user / assign_user_role, to read which dashboard pages/actions a user's role (list_users role_id) allows, and to get permission ids for create_role / update_role. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"list_roles\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/roles","why":"Roles of the tenant with the permissions each grants — the left list of the dashboard Roles & Permissions page and the Roles dropdown of the user edit form. Use it to pick role_id for update_user / assign_user_role, to read which dashboard pages/actions a user's role (list_users role_id) allows, and to get permission ids for create_role / update_role.","response":{"description":"200 → { success: true, data: [role] } — non-deleted roles of the session tenant, sorted by id ascending (the SQL's created_at order is lost when grouping), each with its non-deleted permissions sorted by id. No member counts. 500 → { error: \"Failed to get roles\" } (e.g. any query parameter: every query key becomes a roles-column filter, so send none). Verified live on dev1.","fields":{"success":"boolean. true.","data":"array. One object per role.","data[].id":"int. Role id. Pass to get_role / update_role, as role_id to update_user / assign_user_role; list_users [].role_id points here.","data[].name":"string. Role name, unique per tenant (case- and accent-insensitive). A name containing \"admin\" gives assigned users is_admin 1 and admin POS flags; with update_user any name other than exactly \"staff\" does.","data[].description":"string | null. Free text shown under the name on the dashboard Roles page.","data[].customer_id":"int. Tenant id — always your own; not needed.","data[].is_deleted":"0. Always 0 (deleted roles are filtered out).","data[].created_at":"string (ISO timestamp). Creation time.","data[].updated_at":"string (ISO timestamp). Last change of name/description (permission-only changes do not move it).","data[].permissions":"array. Permissions granted by this role (non-deleted permissions only); [] = none.","data[].permissions[].id":"int. Permission id — what create_role / update_role take in `permissions`.","data[].permissions[].name":"string. Label, e.g. \"Brands\" (page) or \"Add\" (action).","data[].permissions[].value":"string. Code: \"<group>.<page>\" for page access (e.g. \"products.brands\"), \"<group>.<page>.<action>\" for actions (\"products.brands.add\"); legacy rows use other codes (\"module_pos\", \"Inventory_Count\").","data[].permissions[].group":"string. Module, e.g. Dashboard, CRM, Products, Inventory, Reporting, Config, POS, Payments, Users, Attendance; legacy groups: module_access, web_permissions, pos_permissions, ios_*, policy_fields, …","data[].permissions[].sub_group":"string | null. Page inside the module (e.g. \"Brands\"). null = legacy flat permission, not shown in the dashboard role editor.","data[].permissions[].action":"string | null. null = the page-access row; else the action (add, edit, delete, export, import, manage, create, …)."},"example":{"success":true,"data":[{"id":32,"name":"Staff","description":"Default staff role with module group permissions","customer_id":437,"is_deleted":0,"created_at":"2025-09-29T08:05:50.000Z","updated_at":"2025-09-29T08:05:50.000Z","permissions":[{"id":1,"name":"Manage Inventory Count","value":"Inventory_Count","group":"inventory","sub_group":null,"action":null},{"id":139,"name":"Attendance","value":"module_attendance","group":"module_access","sub_group":null,"action":null},{"id":629,"name":"Roles","value":"users.roles","group":"Users","sub_group":"Roles","action":null},{"id":630,"name":"Add","value":"users.roles.add","group":"Users","sub_group":"Roles","action":"add"}]},{"id":182,"name":"Manager","description":"","customer_id":437,"is_deleted":0,"created_at":"2025-12-13T13:24:49.000Z","updated_at":"2025-12-13T13:24:49.000Z","permissions":[{"id":139,"name":"Attendance","value":"module_attendance","group":"module_access","sub_group":null,"action":null},{"id":140,"name":"Configuration","value":"module_config","group":"module_access","sub_group":null,"action":null}]}]},"master_rules":["The dashboard calls it with no params: the Roles & Permissions page (/dashboard/roles) lists every role (name, description) and opens the first; the user edit form fills its Roles dropdown (name, value id) and shows \"Current role: <name>\" for the user's role_id.","Permission model (dashboard Roles & Permissions page): rows with a sub_group form the editor tree — module (group) → page (sub_group; its row with action null is the page access) → actions (rows with action set). Rows without sub_group are legacy and hidden in the editor but kept on save. The full catalogue comes from GET /api/permissions (no MCP tool); the tenant's default \"Admin\" role normally carries every permission, so read ids from its permissions[] (list_roles).","Effective dashboard permissions of a user = permissions of the role in list_users role_id + direct per-user permissions (dashboard-only). They do not change POS switches — those are list_users permissions / named switches and update_user_permissions.","Deleting a role stays in the dashboard (MCP never deletes roles)."]},"parameters":{"type":"object","properties":{},"required":[]},"module":"users","safety":"read"},{"name":"get_role","description":"GET /api/roles/:id. Why: One role with its permissions — read it before update_role (which replaces the permission list) or to explain what a user's role (list_users role_id) lets them do in the dashboard. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"get_role\"} when you need field meanings. Safety: read. customer_id is taken from the MCP session — do not send it.","endpoint":"GET /api/roles/:id","why":"One role with its permissions — read it before update_role (which replaces the permission list) or to explain what a user's role (list_users role_id) lets them do in the dashboard.","response":{"description":"200 → { success: true, data: role }. 404 → { message: \"Role not found\" } for an unknown / deleted / other-tenant id — and also for a role whose every permission has been soft-deleted. 500 → { error: \"Failed to get role\" }. Permissions are in link order (not sorted). Verified live on dev1.","fields":{"success":"boolean. true.","data":"object. The role.","data.id":"int. Role id. Pass to get_role / update_role, as role_id to update_user / assign_user_role; list_users [].role_id points here.","data.name":"string. Role name, unique per tenant (case- and accent-insensitive). A name containing \"admin\" gives assigned users is_admin 1 and admin POS flags; with update_user any name other than exactly \"staff\" does.","data.description":"string | null. Free text shown under the name on the dashboard Roles page.","data.customer_id":"int. Tenant id — always your own; not needed.","data.is_deleted":"0. Always 0 (deleted roles are filtered out).","data.created_at":"string (ISO timestamp). Creation time.","data.updated_at":"string (ISO timestamp). Last change of name/description (permission-only changes do not move it).","data.permissions":"array. Permissions granted by this role (non-deleted permissions only); [] = none.","data.permissions[].id":"int. Permission id — what create_role / update_role take in `permissions`.","data.permissions[].name":"string. Label, e.g. \"Brands\" (page) or \"Add\" (action).","data.permissions[].value":"string. Code: \"<group>.<page>\" for page access (e.g. \"products.brands\"), \"<group>.<page>.<action>\" for actions (\"products.brands.add\"); legacy rows use other codes (\"module_pos\", \"Inventory_Count\").","data.permissions[].group":"string. Module, e.g. Dashboard, CRM, Products, Inventory, Reporting, Config, POS, Payments, Users, Attendance; legacy groups: module_access, web_permissions, pos_permissions, ios_*, policy_fields, …","data.permissions[].sub_group":"string | null. Page inside the module (e.g. \"Brands\"). null = legacy flat permission, not shown in the dashboard role editor.","data.permissions[].action":"string | null. null = the page-access row; else the action (add, edit, delete, export, import, manage, create, …)."},"example":{"success":true,"data":{"id":182,"name":"Manager","description":"","customer_id":437,"is_deleted":0,"created_at":"2025-12-13T13:24:49.000Z","updated_at":"2025-12-13T13:24:49.000Z","permissions":[{"id":139,"name":"Attendance","value":"module_attendance","group":"module_access","sub_group":null,"action":null},{"id":140,"name":"Configuration","value":"module_config","group":"module_access","sub_group":null,"action":null},{"id":141,"name":"Dashboard","value":"module_dashboard","group":"module_access","sub_group":null,"action":null}]}},"master_rules":["The dashboard does not call this route (the Roles page works from list_roles); it is the MCP way to read one role before update_role.","Permission model (dashboard Roles & Permissions page): rows with a sub_group form the editor tree — module (group) → page (sub_group; its row with action null is the page access) → actions (rows with action set). Rows without sub_group are legacy and hidden in the editor but kept on save. The full catalogue comes from GET /api/permissions (no MCP tool); the tenant's default \"Admin\" role normally carries every permission, so read ids from its permissions[] (list_roles).","Users holding this role: list_users rows with this role_id."]},"parameters":{"type":"object","properties":{"id":{"type":"string","description":"Role id (list_roles data[].id)."}},"required":["id"]},"module":"users","safety":"read"},{"name":"create_role","description":"POST /api/roles. Why: Create a role with a set of dashboard permissions — what the Roles & Permissions page's \"New role\" / Duplicate does. Assign it to users with update_user (role_id) or assign_user_role. Permission ids come from list_roles / get_role. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"create_role\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"POST /api/roles","why":"Create a role with a set of dashboard permissions — what the Roles & Permissions page's \"New role\" / Duplicate does. Assign it to users with update_user (role_id) or assign_user_role. Permission ids come from list_roles / get_role.","response":{"description":"201 → { success: true, message: \"Role created successfully\", data: role } (fresh read). 400 { success: false, message: \"Missing required fields: 'name' or 'customer_id'.\" }; 409 { success: false, message: \"Role with name \\\"<name>\\\" already exists for this customer.\" }; 500 { error: \"Failed to create role\" } — not transactional: the role may already exist without its permissions, check list_roles before retrying. data is null when every granted permission is soft-deleted.","fields":{"success":"boolean. true.","message":"string. \"Role created successfully\".","data":"object | null. The new role, read back.","data.id":"int. Role id. Pass to get_role / update_role, as role_id to update_user / assign_user_role; list_users [].role_id points here.","data.name":"string. Role name, unique per tenant (case- and accent-insensitive). A name containing \"admin\" gives assigned users is_admin 1 and admin POS flags; with update_user any name other than exactly \"staff\" does.","data.description":"string | null. Free text shown under the name on the dashboard Roles page.","data.customer_id":"int. Tenant id — always your own; not needed.","data.is_deleted":"0. Always 0 (deleted roles are filtered out).","data.created_at":"string (ISO timestamp). Creation time.","data.updated_at":"string (ISO timestamp). Last change of name/description (permission-only changes do not move it).","data.permissions":"array. Permissions granted by this role (non-deleted permissions only); [] = none.","data.permissions[].id":"int. Permission id — what create_role / update_role take in `permissions`.","data.permissions[].name":"string. Label, e.g. \"Brands\" (page) or \"Add\" (action).","data.permissions[].value":"string. Code: \"<group>.<page>\" for page access (e.g. \"products.brands\"), \"<group>.<page>.<action>\" for actions (\"products.brands.add\"); legacy rows use other codes (\"module_pos\", \"Inventory_Count\").","data.permissions[].group":"string. Module, e.g. Dashboard, CRM, Products, Inventory, Reporting, Config, POS, Payments, Users, Attendance; legacy groups: module_access, web_permissions, pos_permissions, ios_*, policy_fields, …","data.permissions[].sub_group":"string | null. Page inside the module (e.g. \"Brands\"). null = legacy flat permission, not shown in the dashboard role editor.","data.permissions[].action":"string | null. null = the page-access row; else the action (add, edit, delete, export, import, manage, create, …)."},"example_request":{"name":"Shift Supervisor","description":"Brands and users read access","permissions":[403,405,625]},"example":{"success":true,"message":"Role created successfully","data":{"id":190,"name":"Shift Supervisor","description":"Brands and users read access","customer_id":437,"is_deleted":0,"created_at":"2026-09-26T09:00:00.000Z","updated_at":"2026-09-26T09:00:00.000Z","permissions":[{"id":403,"name":"Access","value":"dashboard.access","group":"Dashboard","sub_group":"Dashboard Access","action":null},{"id":405,"name":"Brands","value":"products.brands","group":"Products","sub_group":"Brands","action":null},{"id":625,"name":"List","value":"users.list","group":"Users","sub_group":"List","action":null}]}},"master_rules":["Dashboard: \"New role\" adds a local draft named \"New role\" with the first page access ticked; nothing is saved until Save, which sends { name, description, permissions: [every ticked id] }. Duplicate saves at once { name: \"<name> (copy)\", description, permissions: <same ids> }. There is no other validation.","Permission model (dashboard Roles & Permissions page): rows with a sub_group form the editor tree — module (group) → page (sub_group; its row with action null is the page access) → actions (rows with action set). Rows without sub_group are legacy and hidden in the editor but kept on save. The full catalogue comes from GET /api/permissions (no MCP tool); the tenant's default \"Admin\" role normally carries every permission, so read ids from its permissions[] (list_roles).","Then assign it: update_user role_id (full record) or assign_user_role."]},"parameters":{"type":"object","properties":{"name":{"type":"string","description":"Role name, unique in the tenant (409 when taken)."},"description":{"type":"string","description":"Free text shown on the Roles page."},"permissions":{"type":"array","items":{"type":"string","description":"Permission id."},"description":"Permission ids (list_roles / get_role permissions[].id) — the COMPLETE set the role should grant."}},"required":["name"]},"module":"users","safety":"write"},{"name":"update_role","description":"PUT /api/roles/:id. Why: Rename a role or change the dashboard permissions it grants — what Save on the Roles & Permissions page does. Affects every user with this role_id (list_users) immediately. Read it with get_role first. Send name AND description AND the complete permissions list like the dashboard: permissions replace the whole set ([] removes all), and a body with neither name nor description fails with 500. Full guide (every response field, rules, caveats, examples): call describe_tool {\"name\":\"update_role\"} BEFORE calling this tool. Safety: write. customer_id is taken from the MCP session — do not send it.","endpoint":"PUT /api/roles/:id","why":"Rename a role or change the dashboard permissions it grants — what Save on the Roles & Permissions page does. Affects every user with this role_id (list_users) immediately. Read it with get_role first.","response":{"description":"200 → { success: true, message: \"Role updated successfully\", data: role } (fresh read; null when every granted permission is soft-deleted). 404 → { message: \"Role not found\" } (unknown / deleted / other tenant); 409 → { success: false, message: \"Role with name \\\"<name>\\\" already exists for this customer.\" }; 500 → { error: \"Failed to update role\" } (e.g. no name/description, permissions not an array).","fields":{"success":"boolean. true.","message":"string. \"Role updated successfully\".","data":"object | null. The role after the update.","data.id":"int. Role id. Pass to get_role / update_role, as role_id to update_user / assign_user_role; list_users [].role_id points here.","data.name":"string. Role name, unique per tenant (case- and accent-insensitive). A name containing \"admin\" gives assigned users is_admin 1 and admin POS flags; with update_user any name other than exactly \"staff\" does.","data.description":"string | null. Free text shown under the name on the dashboard Roles page.","data.customer_id":"int. Tenant id — always your own; not needed.","data.is_deleted":"0. Always 0 (deleted roles are filtered out).","data.created_at":"string (ISO timestamp). Creation time.","data.updated_at":"string (ISO timestamp). Last change of name/description (permission-only changes do not move it).","data.permissions":"array. Permissions granted by this role (non-deleted permissions only); [] = none.","data.permissions[].id":"int. Permission id — what create_role / update_role take in `permissions`.","data.permissions[].name":"string. Label, e.g. \"Brands\" (page) or \"Add\" (action).","data.permissions[].value":"string. Code: \"<group>.<page>\" for page access (e.g. \"products.brands\"), \"<group>.<page>.<action>\" for actions (\"products.brands.add\"); legacy rows use other codes (\"module_pos\", \"Inventory_Count\").","data.permissions[].group":"string. Module, e.g. Dashboard, CRM, Products, Inventory, Reporting, Config, POS, Payments, Users, Attendance; legacy groups: module_access, web_permissions, pos_permissions, ios_*, policy_fields, …","data.permissions[].sub_group":"string | null. Page inside the module (e.g. \"Brands\"). null = legacy flat permission, not shown in the dashboard role editor.","data.permissions[].action":"string | null. null = the page-access row; else the action (add, edit, delete, export, import, manage, create, …)."},"example_request":{"id":182,"name":"Manager","description":"Store manager","permissions":[139,140,141,142,143,144,145,146,147,148,149,150,405,406]},"example":{"success":true,"message":"Role updated successfully","data":{"id":182,"name":"Manager","description":"Store manager","customer_id":437,"is_deleted":0,"created_at":"2025-12-13T13:24:49.000Z","updated_at":"2025-12-13T13:24:49.000Z","permissions":[{"id":139,"name":"Attendance","value":"module_attendance","group":"module_access","sub_group":null,"action":null},{"id":405,"name":"Brands","value":"products.brands","group":"Products","sub_group":"Brands","action":null},{"id":406,"name":"Add","value":"products.brands.add","group":"Products","sub_group":"Brands","action":"add"}]}},"master_rules":["The dashboard Save always sends { name, description, permissions: [every id the role has after the edit] } — including legacy ids it does not display. Do the same: start from get_role permissions[].id, add/remove ids, send the full list.","Editor behaviour to mirror: ticking an action also ticks its page access; unticking a page access unticks its actions; module-level \"all\" / \"none\" tick or untick every page and action of that group. Discard restores the last saved state (no request).","Permission model (dashboard Roles & Permissions page): rows with a sub_group form the editor tree — module (group) → page (sub_group; its row with action null is the page access) → actions (rows with action set). Rows without sub_group are legacy and hidden in the editor but kept on save. The full catalogue comes from GET /api/permissions (no MCP tool); the tenant's default \"Admin\" role normally carries every permission, so read ids from its permissions[] (list_roles).","The change applies to every user with this role_id at once (dashboard footer: \"Updating this role affects all members assigned to it\"). It does not change users.role / is_admin; renaming to a name containing \"admin\" only matters when users are next saved or assigned."]},"parameters":{"type":"object","properties":{"id":{"type":"string","description":"Role id (list_roles data[].id)."},"name":{"type":"string","description":"Role name (409 if another role has it)."},"description":{"type":"string","description":"Free text."},"permissions":{"type":"array","items":{"type":"string","description":"Permission id."},"description":"Permission ids (list_roles / get_role permissions[].id) — the COMPLETE set the role should grant."}},"required":["id","name"]},"module":"users","safety":"write"},{"name":"describe_tool","description":"Full documentation of another tool: every param, every response field with its meaning, the rules (how the POS/dashboard uses it and how it links to other tools), caveats and real examples. Read-only, no SysPOS call. Use it before any write tool and whenever you need to know what a response field means.","endpoint":"LOCAL (local)","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Exact tool name, e.g. \"sync_invoice\" or \"list_selling_products\"."}},"required":["name"]},"module":"meta","safety":"read"}]}