Auto-commit local changes before build (2026-01-15 14:08:59)

This commit is contained in:
Ivo Oskamp 2026-01-15 14:08:59 +01:00
parent 5b9b6f4c38
commit c68b401709
7 changed files with 580 additions and 6 deletions

View File

@ -1 +1 @@
v20260115-08-autotask-entityinfo-fields-shape-fix v20260115-09-autotask-customer-company-mapping

View File

@ -14,7 +14,9 @@ class AutotaskZoneInfo:
class AutotaskError(RuntimeError): class AutotaskError(RuntimeError):
pass def __init__(self, message: str, status_code: Optional[int] = None) -> None:
super().__init__(message)
self.status_code = status_code
class AutotaskClient: class AutotaskClient:
@ -141,13 +143,18 @@ class AutotaskClient:
"Authentication failed (HTTP 401). " "Authentication failed (HTTP 401). "
"Verify API Username, API Secret, and ApiIntegrationCode. " "Verify API Username, API Secret, and ApiIntegrationCode. "
f"Environment={self.environment}, ZoneInfoBase={zi_base}, ZoneApiUrl={zone.api_url}." f"Environment={self.environment}, ZoneInfoBase={zi_base}, ZoneApiUrl={zone.api_url}."
,
status_code=401,
) )
if resp.status_code == 403: if resp.status_code == 403:
raise AutotaskError("Access forbidden (HTTP 403). API user permissions may be insufficient.") raise AutotaskError(
"Access forbidden (HTTP 403). API user permissions may be insufficient.",
status_code=403,
)
if resp.status_code == 404: if resp.status_code == 404:
raise AutotaskError(f"Resource not found (HTTP 404) for path: {path}") raise AutotaskError(f"Resource not found (HTTP 404) for path: {path}", status_code=404)
if resp.status_code >= 400: if resp.status_code >= 400:
raise AutotaskError(f"Autotask API error (HTTP {resp.status_code}).") raise AutotaskError(f"Autotask API error (HTTP {resp.status_code}).", status_code=resp.status_code)
try: try:
return resp.json() return resp.json()
@ -225,6 +232,58 @@ class AutotaskClient:
""" """
return self._get_ticket_picklist_values(field_names=["source", "sourceid"]) return self._get_ticket_picklist_values(field_names=["source", "sourceid"])
def search_companies(self, query: str, limit: int = 25) -> List[Dict[str, Any]]:
"""Search Companies by company name.
Uses the standard REST query endpoint:
GET /Companies/query?search={...}
Returns a minimal list of dicts with keys: id, companyName, isActive.
"""
q = (query or "").strip()
if not q:
return []
# Keep payload small and predictable.
# Field names in filters are case-insensitive in many tenants, but the docs
# commonly show CompanyName.
search_payload: Dict[str, Any] = {
"filter": [
{"op": "contains", "field": "CompanyName", "value": q},
],
"maxRecords": int(limit) if int(limit) > 0 else 25,
}
params = {"search": json.dumps(search_payload)}
data = self._request("GET", "Companies/query", params=params)
items = self._as_items_list(data)
out: List[Dict[str, Any]] = []
for it in items:
if not isinstance(it, dict):
continue
cid = it.get("id")
name = it.get("companyName") or it.get("CompanyName") or ""
try:
cid_int = int(cid)
except Exception:
continue
out.append(
{
"id": cid_int,
"companyName": str(name),
"isActive": bool(it.get("isActive", True)),
}
)
out.sort(key=lambda x: (x.get("companyName") or "").lower())
return out
def get_company(self, company_id: int) -> Dict[str, Any]:
"""Fetch a single Company by ID."""
return self._request("GET", f"Companies/{int(company_id)}")
def _get_ticket_picklist_values(self, field_names: List[str]) -> List[Dict[str, Any]]: def _get_ticket_picklist_values(self, field_names: List[str]) -> List[Dict[str, Any]]:
"""Retrieve picklist values for a Tickets field. """Retrieve picklist values for a Tickets field.

View File

@ -6,6 +6,14 @@ from .routes_shared import * # noqa: F401,F403
def customers(): def customers():
items = Customer.query.order_by(Customer.name.asc()).all() items = Customer.query.order_by(Customer.name.asc()).all()
settings = _get_or_create_settings()
autotask_enabled = bool(getattr(settings, "autotask_enabled", False))
autotask_configured = bool(
(getattr(settings, "autotask_api_username", None))
and (getattr(settings, "autotask_api_password", None))
and (getattr(settings, "autotask_tracking_identifier", None))
)
rows = [] rows = []
for c in items: for c in items:
# Count jobs linked to this customer # Count jobs linked to this customer
@ -19,6 +27,14 @@ def customers():
"name": c.name, "name": c.name,
"active": bool(c.active), "active": bool(c.active),
"job_count": job_count, "job_count": job_count,
"autotask_company_id": getattr(c, "autotask_company_id", None),
"autotask_company_name": getattr(c, "autotask_company_name", None),
"autotask_mapping_status": getattr(c, "autotask_mapping_status", None),
"autotask_last_sync_at": (
getattr(c, "autotask_last_sync_at", None).isoformat(timespec="seconds")
if getattr(c, "autotask_last_sync_at", None)
else None
),
} }
) )
@ -28,9 +44,174 @@ def customers():
"main/customers.html", "main/customers.html",
customers=rows, customers=rows,
can_manage=can_manage, can_manage=can_manage,
autotask_enabled=autotask_enabled,
autotask_configured=autotask_configured,
) )
def _get_autotask_client_or_raise():
"""Build an AutotaskClient from settings or raise a user-safe exception."""
settings = _get_or_create_settings()
if not bool(getattr(settings, "autotask_enabled", False)):
raise RuntimeError("Autotask integration is disabled.")
if not settings.autotask_api_username or not settings.autotask_api_password or not settings.autotask_tracking_identifier:
raise RuntimeError("Autotask settings incomplete.")
from ..integrations.autotask.client import AutotaskClient
return AutotaskClient(
username=settings.autotask_api_username,
password=settings.autotask_api_password,
api_integration_code=settings.autotask_tracking_identifier,
environment=(settings.autotask_environment or "production"),
)
@main_bp.get("/api/autotask/companies/search")
@login_required
@roles_required("admin", "operator")
def api_autotask_companies_search():
q = (request.args.get("q") or "").strip()
if not q:
return jsonify({"status": "ok", "items": []})
try:
client = _get_autotask_client_or_raise()
items = client.search_companies(q, limit=25)
return jsonify({"status": "ok", "items": items})
except Exception as exc:
return jsonify({"status": "error", "message": str(exc) or "Search failed."}), 400
def _normalize_company_name(company: dict) -> str:
return str(company.get("companyName") or company.get("CompanyName") or company.get("name") or "").strip()
@main_bp.get("/api/customers/<int:customer_id>/autotask-mapping")
@login_required
@roles_required("admin", "operator", "viewer")
def api_customer_autotask_mapping_get(customer_id: int):
c = Customer.query.get_or_404(customer_id)
return jsonify(
{
"status": "ok",
"customer": {
"id": c.id,
"autotask_company_id": getattr(c, "autotask_company_id", None),
"autotask_company_name": getattr(c, "autotask_company_name", None),
"autotask_mapping_status": getattr(c, "autotask_mapping_status", None),
"autotask_last_sync_at": (
getattr(c, "autotask_last_sync_at", None).isoformat(timespec="seconds")
if getattr(c, "autotask_last_sync_at", None)
else None
),
},
}
)
@main_bp.post("/api/customers/<int:customer_id>/autotask-mapping")
@login_required
@roles_required("admin", "operator")
def api_customer_autotask_mapping_set(customer_id: int):
c = Customer.query.get_or_404(customer_id)
payload = request.get_json(silent=True) or {}
company_id = payload.get("company_id")
try:
company_id_int = int(company_id)
except Exception:
return jsonify({"status": "error", "message": "Invalid company_id."}), 400
try:
client = _get_autotask_client_or_raise()
company = client.get_company(company_id_int)
name = _normalize_company_name(company)
c.autotask_company_id = company_id_int
c.autotask_company_name = name
c.autotask_mapping_status = "ok"
c.autotask_last_sync_at = datetime.utcnow()
db.session.commit()
return jsonify({"status": "ok"})
except Exception as exc:
db.session.rollback()
return jsonify({"status": "error", "message": str(exc) or "Failed to set mapping."}), 400
@main_bp.post("/api/customers/<int:customer_id>/autotask-mapping/clear")
@login_required
@roles_required("admin", "operator")
def api_customer_autotask_mapping_clear(customer_id: int):
c = Customer.query.get_or_404(customer_id)
try:
c.autotask_company_id = None
c.autotask_company_name = None
c.autotask_mapping_status = None
c.autotask_last_sync_at = datetime.utcnow()
db.session.commit()
return jsonify({"status": "ok"})
except Exception as exc:
db.session.rollback()
return jsonify({"status": "error", "message": str(exc) or "Failed to clear mapping."}), 400
@main_bp.post("/api/customers/<int:customer_id>/autotask-mapping/refresh")
@login_required
@roles_required("admin", "operator")
def api_customer_autotask_mapping_refresh(customer_id: int):
from ..integrations.autotask.client import AutotaskError
c = Customer.query.get_or_404(customer_id)
company_id = getattr(c, "autotask_company_id", None)
if not company_id:
return jsonify({"status": "ok", "mapping_status": None})
try:
client = _get_autotask_client_or_raise()
company = client.get_company(int(company_id))
name = _normalize_company_name(company)
prev = (getattr(c, "autotask_company_name", None) or "").strip()
if prev and name and prev != name:
c.autotask_company_name = name
c.autotask_mapping_status = "renamed"
else:
c.autotask_company_name = name
c.autotask_mapping_status = "ok"
c.autotask_last_sync_at = datetime.utcnow()
db.session.commit()
return jsonify({"status": "ok", "mapping_status": c.autotask_mapping_status, "company_name": c.autotask_company_name})
except AutotaskError as exc:
try:
code = getattr(exc, "status_code", None)
except Exception:
code = None
# 404 -> deleted/missing company in Autotask
if code == 404:
try:
c.autotask_mapping_status = "invalid"
c.autotask_last_sync_at = datetime.utcnow()
db.session.commit()
except Exception:
db.session.rollback()
return jsonify({"status": "ok", "mapping_status": "invalid"})
# Other errors: keep mapping but mark as missing (temporary/unreachable)
try:
c.autotask_mapping_status = "missing"
c.autotask_last_sync_at = datetime.utcnow()
db.session.commit()
except Exception:
db.session.rollback()
return jsonify({"status": "ok", "mapping_status": "missing", "message": str(exc)})
except Exception as exc:
db.session.rollback()
return jsonify({"status": "error", "message": str(exc) or "Refresh failed."}), 400
@main_bp.route("/customers/create", methods=["POST"]) @main_bp.route("/customers/create", methods=["POST"])
@login_required @login_required
@roles_required("admin", "operator") @roles_required("admin", "operator")

View File

@ -188,6 +188,41 @@ def migrate_system_settings_autotask_integration() -> None:
print(f"[migrations] Failed to migrate system_settings autotask integration columns: {exc}") print(f"[migrations] Failed to migrate system_settings autotask integration columns: {exc}")
def migrate_customers_autotask_company_mapping() -> None:
"""Add Autotask company mapping columns to customers if missing.
Columns:
- autotask_company_id (INTEGER NULL)
- autotask_company_name (VARCHAR(255) NULL)
- autotask_mapping_status (VARCHAR(20) NULL)
- autotask_last_sync_at (TIMESTAMP NULL)
"""
table = "customers"
columns = [
("autotask_company_id", "INTEGER NULL"),
("autotask_company_name", "VARCHAR(255) NULL"),
("autotask_mapping_status", "VARCHAR(20) NULL"),
("autotask_last_sync_at", "TIMESTAMP NULL"),
]
try:
engine = db.get_engine()
except Exception as exc:
print(f"[migrations] Could not get engine for customers autotask mapping migration: {exc}")
return
try:
with engine.begin() as conn:
for column, ddl in columns:
if _column_exists_on_conn(conn, table, column):
continue
conn.execute(text(f'ALTER TABLE "{table}" ADD COLUMN {column} {ddl}'))
print("[migrations] migrate_customers_autotask_company_mapping completed.")
except Exception as exc:
print(f"[migrations] Failed to migrate customers autotask company mapping columns: {exc}")
@ -843,6 +878,7 @@ def run_migrations() -> None:
migrate_system_settings_daily_jobs_start_date() migrate_system_settings_daily_jobs_start_date()
migrate_system_settings_ui_timezone() migrate_system_settings_ui_timezone()
migrate_system_settings_autotask_integration() migrate_system_settings_autotask_integration()
migrate_customers_autotask_company_mapping()
migrate_mail_messages_columns() migrate_mail_messages_columns()
migrate_mail_messages_parse_columns() migrate_mail_messages_parse_columns()
migrate_mail_messages_approval_columns() migrate_mail_messages_approval_columns()

View File

@ -153,6 +153,14 @@ class Customer(db.Model):
name = db.Column(db.String(255), unique=True, nullable=False) name = db.Column(db.String(255), unique=True, nullable=False)
active = db.Column(db.Boolean, nullable=False, default=True) active = db.Column(db.Boolean, nullable=False, default=True)
# Autotask company mapping (Phase 3)
# Company ID is leading; name is cached for UI display.
autotask_company_id = db.Column(db.Integer, nullable=True)
autotask_company_name = db.Column(db.String(255), nullable=True)
# Mapping status: ok | renamed | missing | invalid
autotask_mapping_status = db.Column(db.String(20), nullable=True)
autotask_last_sync_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column( updated_at = db.Column(
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False

View File

@ -29,6 +29,8 @@
<th scope="col">Customer</th> <th scope="col">Customer</th>
<th scope="col">Active</th> <th scope="col">Active</th>
<th scope="col">Number of jobs</th> <th scope="col">Number of jobs</th>
<th scope="col">Autotask company</th>
<th scope="col">Autotask mapping</th>
{% if can_manage %} {% if can_manage %}
<th scope="col">Actions</th> <th scope="col">Actions</th>
{% endif %} {% endif %}
@ -46,6 +48,7 @@
<span class="badge bg-secondary">Inactive</span> <span class="badge bg-secondary">Inactive</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if c.job_count > 0 %} {% if c.job_count > 0 %}
{{ c.job_count }} {{ c.job_count }}
@ -53,6 +56,36 @@
<span class="text-danger fw-bold">0</span> <span class="text-danger fw-bold">0</span>
{% endif %} {% endif %}
</td> </td>
<td>
{% if c.autotask_company_id %}
<span class="fw-semibold">{{ c.autotask_company_name or 'Unknown' }}</span>
<div class="text-muted small">ID: {{ c.autotask_company_id }}</div>
{% else %}
<span class="text-muted">Not mapped</span>
{% endif %}
</td>
<td>
{% set st = (c.autotask_mapping_status or '').lower() %}
{% if not c.autotask_company_id %}
<span class="badge bg-secondary">Not mapped</span>
{% elif st == 'ok' %}
<span class="badge bg-success">OK</span>
{% elif st == 'renamed' %}
<span class="badge bg-warning text-dark">Renamed</span>
{% elif st == 'missing' %}
<span class="badge bg-warning text-dark">Missing</span>
{% elif st == 'invalid' %}
<span class="badge bg-danger">Invalid</span>
{% else %}
<span class="badge bg-secondary">Unknown</span>
{% endif %}
{% if c.autotask_last_sync_at %}
<div class="text-muted small">Checked: {{ c.autotask_last_sync_at }}</div>
{% endif %}
</td>
{% if can_manage %} {% if can_manage %}
<td> <td>
<button <button
@ -63,6 +96,10 @@
data-id="{{ c.id }}" data-id="{{ c.id }}"
data-name="{{ c.name }}" data-name="{{ c.name }}"
data-active="{{ '1' if c.active else '0' }}" data-active="{{ '1' if c.active else '0' }}"
data-autotask-company-id="{{ c.autotask_company_id or '' }}"
data-autotask-company-name="{{ c.autotask_company_name or '' }}"
data-autotask-mapping-status="{{ c.autotask_mapping_status or '' }}"
data-autotask-last-sync-at="{{ c.autotask_last_sync_at or '' }}"
> >
Edit Edit
</button> </button>
@ -82,7 +119,7 @@
{% endfor %} {% endfor %}
{% else %} {% else %}
<tr> <tr>
<td colspan="{% if can_manage %}4{% else %}3{% endif %}" class="text-center text-muted py-3"> <td colspan="{% if can_manage %}6{% else %}5{% endif %}" class="text-center text-muted py-3">
No customers found. No customers found.
</td> </td>
</tr> </tr>
@ -130,6 +167,36 @@
Active Active
</label> </label>
</div> </div>
<hr class="my-4" />
<h6 class="mb-2">Autotask mapping</h6>
{% if autotask_enabled and autotask_configured %}
<div class="mb-2">
<div class="small text-muted">Current mapping</div>
<div id="autotaskCurrentMapping" class="fw-semibold">Not mapped</div>
<div id="autotaskCurrentMappingMeta" class="text-muted small"></div>
</div>
<div class="input-group input-group-sm mb-2">
<input type="text" class="form-control" id="autotaskCompanySearch" placeholder="Search Autotask companies" autocomplete="off" />
<button class="btn btn-outline-secondary" type="button" id="autotaskCompanySearchBtn">Search</button>
</div>
<div id="autotaskCompanyResults" class="border rounded p-2" style="max-height: 220px; overflow:auto;"></div>
<div class="d-flex gap-2 mt-2">
<button type="button" class="btn btn-sm btn-outline-primary" id="autotaskSetMappingBtn" disabled>Set mapping</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="autotaskRefreshMappingBtn">Refresh status</button>
<button type="button" class="btn btn-sm btn-outline-danger" id="autotaskClearMappingBtn">Clear mapping</button>
</div>
<div id="autotaskMappingMsg" class="small text-muted mt-2"></div>
{% else %}
<div class="text-muted small">
Autotask integration is not available. Enable and configure it in Settings → Extensions &amp; Integrations → Autotask.
</div>
{% endif %}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
@ -152,6 +219,89 @@
var nameInput = document.getElementById("edit_customer_name"); var nameInput = document.getElementById("edit_customer_name");
var activeInput = document.getElementById("edit_customer_active"); var activeInput = document.getElementById("edit_customer_active");
// Autotask mapping UI (only present when integration is enabled/configured)
var atCurrent = document.getElementById("autotaskCurrentMapping");
var atCurrentMeta = document.getElementById("autotaskCurrentMappingMeta");
var atSearchInput = document.getElementById("autotaskCompanySearch");
var atSearchBtn = document.getElementById("autotaskCompanySearchBtn");
var atResults = document.getElementById("autotaskCompanyResults");
var atMsg = document.getElementById("autotaskMappingMsg");
var atSetBtn = document.getElementById("autotaskSetMappingBtn");
var atRefreshBtn = document.getElementById("autotaskRefreshMappingBtn");
var atClearBtn = document.getElementById("autotaskClearMappingBtn");
var currentCustomerId = null;
var selectedCompanyId = null;
function setMsg(text, isError) {
if (!atMsg) {
return;
}
atMsg.textContent = text || "";
if (isError) {
atMsg.classList.remove("text-muted");
atMsg.classList.add("text-danger");
} else {
atMsg.classList.remove("text-danger");
atMsg.classList.add("text-muted");
}
}
function renderCurrentMapping(companyId, companyName, mappingStatus, lastSyncAt) {
if (!atCurrent || !atCurrentMeta) {
return;
}
if (!companyId) {
atCurrent.textContent = "Not mapped";
atCurrentMeta.textContent = "";
return;
}
atCurrent.textContent = (companyName || "Unknown") + " (ID: " + companyId + ")";
var parts = [];
if (mappingStatus) {
parts.push("Status: " + mappingStatus);
}
if (lastSyncAt) {
parts.push("Checked: " + lastSyncAt);
}
atCurrentMeta.textContent = parts.join(" • ");
}
function clearResults() {
if (!atResults) {
return;
}
atResults.innerHTML = "<div class=\"text-muted small\">No results.</div>";
}
function setSelectedCompanyId(cid) {
selectedCompanyId = cid;
if (atSetBtn) {
atSetBtn.disabled = !selectedCompanyId;
}
}
async function postJson(url, body) {
var resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify(body || {}),
});
var data = null;
try {
data = await resp.json();
} catch (e) {
data = null;
}
if (!resp.ok) {
var msg = (data && data.message) ? data.message : ("Request failed (" + resp.status + ").");
throw new Error(msg);
}
return data;
}
var editButtons = document.querySelectorAll(".customer-edit-btn"); var editButtons = document.querySelectorAll(".customer-edit-btn");
editButtons.forEach(function (btn) { editButtons.forEach(function (btn) {
btn.addEventListener("click", function () { btn.addEventListener("click", function () {
@ -165,8 +315,140 @@
if (id) { if (id) {
editForm.action = "{{ url_for('main.customers_edit', customer_id=0) }}".replace("0", id); editForm.action = "{{ url_for('main.customers_edit', customer_id=0) }}".replace("0", id);
} }
// Autotask: seed current mapping from row data attributes
currentCustomerId = id || null;
if (atResults) {
clearResults();
}
setSelectedCompanyId(null);
setMsg("", false);
if (atCurrent) {
var atCompanyId = btn.getAttribute("data-autotask-company-id") || "";
var atCompanyName = btn.getAttribute("data-autotask-company-name") || "";
var atStatus = btn.getAttribute("data-autotask-mapping-status") || "";
var atLast = btn.getAttribute("data-autotask-last-sync-at") || "";
renderCurrentMapping(atCompanyId, atCompanyName, atStatus, atLast);
}
}); });
}); });
if (atSearchBtn && atSearchInput && atResults) {
atSearchBtn.addEventListener("click", async function () {
var q = (atSearchInput.value || "").trim();
if (!q) {
setMsg("Enter a search term.", true);
return;
}
setMsg("Searching...", false);
setSelectedCompanyId(null);
atResults.innerHTML = "<div class=\"text-muted small\">Searching...</div>";
try {
var resp = await fetch("/api/autotask/companies/search?q=" + encodeURIComponent(q), {
method: "GET",
credentials: "same-origin",
});
var data = await resp.json();
if (!resp.ok || !data || data.status !== "ok") {
throw new Error((data && data.message) ? data.message : "Search failed.");
}
var items = data.items || [];
if (!items.length) {
atResults.innerHTML = "<div class=\"text-muted small\">No companies found.</div>";
setMsg("No companies found.", false);
return;
}
var html = "";
items.forEach(function (it) {
var cid = it.id;
var name = it.companyName || it.name || ("Company #" + cid);
var active = (it.isActive === false) ? " (inactive)" : "";
html +=
"<div class=\"form-check\">" +
"<input class=\"form-check-input\" type=\"radio\" name=\"autotaskCompanyPick\" id=\"at_company_" + cid + "\" value=\"" + cid + "\" />" +
"<label class=\"form-check-label\" for=\"at_company_" + cid + "\">" +
name.replace(/</g, "&lt;").replace(/>/g, "&gt;") +
" <span class=\"text-muted\">(ID: " + cid + ")</span>" +
"<span class=\"text-muted\">" + active + "</span>" +
"</label>" +
"</div>";
});
atResults.innerHTML = html;
var radios = atResults.querySelectorAll("input[name='autotaskCompanyPick']");
radios.forEach(function (r) {
r.addEventListener("change", function () {
setSelectedCompanyId(r.value);
setMsg("Selected company ID: " + r.value, false);
});
});
setMsg("Select a company and click Set mapping.", false);
} catch (e) {
atResults.innerHTML = "<div class=\"text-muted small\">No results.</div>";
setMsg(e && e.message ? e.message : "Search failed.", true);
}
});
}
if (atSetBtn) {
atSetBtn.addEventListener("click", async function () {
if (!currentCustomerId) {
setMsg("No customer selected.", true);
return;
}
if (!selectedCompanyId) {
setMsg("Select a company first.", true);
return;
}
atSetBtn.disabled = true;
setMsg("Saving mapping...", false);
try {
await postJson("/api/customers/" + currentCustomerId + "/autotask-mapping", { company_id: selectedCompanyId });
window.location.reload();
} catch (e) {
setMsg(e && e.message ? e.message : "Failed to set mapping.", true);
atSetBtn.disabled = false;
}
});
}
if (atRefreshBtn) {
atRefreshBtn.addEventListener("click", async function () {
if (!currentCustomerId) {
setMsg("No customer selected.", true);
return;
}
setMsg("Refreshing status...", false);
try {
await postJson("/api/customers/" + currentCustomerId + "/autotask-mapping/refresh", {});
window.location.reload();
} catch (e) {
setMsg(e && e.message ? e.message : "Refresh failed.", true);
}
});
}
if (atClearBtn) {
atClearBtn.addEventListener("click", async function () {
if (!currentCustomerId) {
setMsg("No customer selected.", true);
return;
}
if (!confirm("Clear Autotask mapping for this customer?")) {
return;
}
setMsg("Clearing mapping...", false);
try {
await postJson("/api/customers/" + currentCustomerId + "/autotask-mapping/clear", {});
window.location.reload();
} catch (e) {
setMsg(e && e.message ? e.message : "Clear failed.", true);
}
});
}
}); });
})(); })();
</script> </script>

View File

@ -73,6 +73,14 @@ Changes:
- Resolved failures in queue, source, and priority picklist detection caused by empty or misparsed field metadata. - Resolved failures in queue, source, and priority picklist detection caused by empty or misparsed field metadata.
- Stabilized Autotask connection testing across sandbox environments with differing metadata formats. - Stabilized Autotask connection testing across sandbox environments with differing metadata formats.
## v20260115-09-autotask-customer-company-mapping
- Added explicit Autotask company mapping to customers using ID-based linkage.
- Extended customer data model with Autotask company ID, cached company name, mapping status, and last sync timestamp.
- Implemented Autotask company search and lookup endpoints for customer mapping.
- Added mapping status handling to detect renamed, missing, or invalid Autotask companies.
- Updated Customers UI to allow searching, selecting, refreshing, and clearing Autotask company mappings.
- Ensured mappings remain stable when Autotask company names change and block future ticket actions when mappings are invalid.
*** ***