Auto-commit local changes before build (2026-01-20 13:10:45)
This commit is contained in:
parent
fc0cf1ef96
commit
92c67805e5
@ -1 +1 @@
|
||||
v20260120-07-autotask-psa-resolution-handling
|
||||
v20260120-08-runchecks-link-existing-autotask-ticket
|
||||
|
||||
@ -601,3 +601,59 @@ class AutotaskClient:
|
||||
|
||||
data = self._request("POST", "Tickets/query", json_body={"filter": flt})
|
||||
return self._as_items_list(data)
|
||||
|
||||
def query_tickets_for_company(
|
||||
self,
|
||||
company_id: int,
|
||||
*,
|
||||
search: str = "",
|
||||
exclude_status_ids: Optional[List[int]] = None,
|
||||
limit: int = 50,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Query Tickets for a specific company, optionally searching by ticket number or title.
|
||||
|
||||
Uses POST /Tickets/query.
|
||||
|
||||
Note:
|
||||
- Autotask query operators vary by tenant; we use common operators (eq, contains).
|
||||
- If the query fails due to operator support, callers should fall back to get_ticket(id).
|
||||
"""
|
||||
|
||||
try:
|
||||
cid = int(company_id)
|
||||
except Exception:
|
||||
cid = 0
|
||||
if cid <= 0:
|
||||
return []
|
||||
|
||||
flt: List[Dict[str, Any]] = [
|
||||
{"op": "eq", "field": "companyID", "value": cid},
|
||||
]
|
||||
|
||||
ex: List[int] = []
|
||||
for x in exclude_status_ids or []:
|
||||
try:
|
||||
v = int(x)
|
||||
except Exception:
|
||||
continue
|
||||
if v > 0:
|
||||
ex.append(v)
|
||||
if ex:
|
||||
flt.append({"op": "notIn", "field": "status", "value": ex})
|
||||
|
||||
q = (search or "").strip()
|
||||
if q:
|
||||
# Ticket numbers in Autotask are typically like T20260119.0004
|
||||
if q.upper().startswith("T") and any(ch.isdigit() for ch in q):
|
||||
flt.append({"op": "eq", "field": "ticketNumber", "value": q.strip()})
|
||||
else:
|
||||
# Broad search on title
|
||||
flt.append({"op": "contains", "field": "title", "value": q})
|
||||
|
||||
data = self._request("POST", "Tickets/query", json_body={"filter": flt})
|
||||
items = self._as_items_list(data)
|
||||
|
||||
# Respect limit if tenant returns more.
|
||||
if limit and isinstance(limit, int) and limit > 0:
|
||||
return items[: int(limit)]
|
||||
return items
|
||||
|
||||
@ -427,8 +427,8 @@ def _poll_autotask_ticket_states_for_runs(*, run_ids: list[int]) -> None:
|
||||
else:
|
||||
resolved_at_raw = None
|
||||
if resolved_at_raw:
|
||||
s = str(resolved_at_raw).replace("Z", "+00:00")
|
||||
resolved_at = datetime.fromisoformat(s)
|
||||
s_dt = str(resolved_at_raw).replace("Z", "+00:00")
|
||||
resolved_at = datetime.fromisoformat(s_dt)
|
||||
if resolved_at.tzinfo is not None:
|
||||
resolved_at = resolved_at.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
except Exception:
|
||||
@ -446,7 +446,7 @@ def _poll_autotask_ticket_states_for_runs(*, run_ids: list[int]) -> None:
|
||||
job=job,
|
||||
run_ids=[int(x.id) for x in runs_for_ticket if getattr(x, "id", None)],
|
||||
now=resolved_at or now,
|
||||
origin="Resolved by PSA",
|
||||
origin="psa",
|
||||
)
|
||||
|
||||
try:
|
||||
@ -1567,6 +1567,244 @@ def api_run_checks_create_autotask_ticket():
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@main_bp.get("/api/run-checks/autotask-existing-tickets")
|
||||
@login_required
|
||||
@roles_required("admin", "operator")
|
||||
def api_run_checks_autotask_existing_tickets():
|
||||
"""List open (non-terminal) Autotask tickets for the selected run's customer.
|
||||
|
||||
Phase 2.2: used by the Run Checks modal to link an existing PSA ticket.
|
||||
"""
|
||||
|
||||
try:
|
||||
run_id = int(request.args.get("run_id") or 0)
|
||||
except Exception:
|
||||
run_id = 0
|
||||
|
||||
q = (request.args.get("q") or "").strip()
|
||||
|
||||
if run_id <= 0:
|
||||
return jsonify({"status": "error", "message": "Invalid parameters."}), 400
|
||||
|
||||
run = JobRun.query.get(run_id)
|
||||
if not run:
|
||||
return jsonify({"status": "error", "message": "Run not found."}), 404
|
||||
|
||||
job = Job.query.get(run.job_id)
|
||||
if not job:
|
||||
return jsonify({"status": "error", "message": "Job not found."}), 404
|
||||
|
||||
customer = Customer.query.get(job.customer_id) if getattr(job, "customer_id", None) else None
|
||||
if not customer:
|
||||
return jsonify({"status": "error", "message": "Customer not found."}), 404
|
||||
|
||||
if not getattr(customer, "autotask_company_id", None):
|
||||
return jsonify({"status": "error", "message": "Customer has no Autotask company mapping."}), 400
|
||||
|
||||
if (getattr(customer, "autotask_mapping_status", None) or "").strip().lower() not in ("ok", "renamed"):
|
||||
return jsonify({"status": "error", "message": "Autotask company mapping is not valid."}), 400
|
||||
|
||||
settings = _get_or_create_settings()
|
||||
|
||||
# Map status ID -> label from cached settings (kept in sync by Settings page).
|
||||
status_map = {}
|
||||
try:
|
||||
import json as _json
|
||||
|
||||
raw = getattr(settings, "autotask_cached_ticket_statuses_json", None)
|
||||
if raw:
|
||||
for x in (_json.loads(raw) or []):
|
||||
if isinstance(x, dict) and "value" in x:
|
||||
status_map[str(x.get("value"))] = str(x.get("label") or "")
|
||||
except Exception:
|
||||
status_map = {}
|
||||
|
||||
try:
|
||||
client = _build_autotask_client_from_settings()
|
||||
|
||||
# Ensure we have a status map; if empty, fetch and cache once.
|
||||
if not status_map:
|
||||
try:
|
||||
import json as _json
|
||||
|
||||
statuses = client.get_ticket_statuses()
|
||||
settings.autotask_cached_ticket_statuses_json = _json.dumps(statuses)
|
||||
settings.autotask_reference_last_sync_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
for x in (statuses or []):
|
||||
if isinstance(x, dict) and "value" in x:
|
||||
status_map[str(x.get("value"))] = str(x.get("label") or "")
|
||||
except Exception:
|
||||
# Best-effort; list will still work without labels.
|
||||
pass
|
||||
|
||||
tickets = client.query_tickets_for_company(
|
||||
int(customer.autotask_company_id),
|
||||
search=q,
|
||||
exclude_status_ids=sorted(AUTOTASK_TERMINAL_STATUS_IDS),
|
||||
limit=75,
|
||||
)
|
||||
except Exception as exc:
|
||||
return jsonify({"status": "error", "message": f"Autotask ticket lookup failed: {exc}"}), 400
|
||||
|
||||
items = []
|
||||
for t in tickets or []:
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
tid = t.get("id")
|
||||
tnum = (t.get("ticketNumber") or t.get("number") or "")
|
||||
title = (t.get("title") or "")
|
||||
st = t.get("status")
|
||||
try:
|
||||
st_int = int(st) if st is not None else None
|
||||
except Exception:
|
||||
st_int = None
|
||||
st_label = status_map.get(str(st_int)) if st_int is not None else ""
|
||||
items.append(
|
||||
{
|
||||
"id": tid,
|
||||
"ticketNumber": str(tnum or ""),
|
||||
"title": str(title or ""),
|
||||
"status": st_int,
|
||||
"statusLabel": st_label or "",
|
||||
}
|
||||
)
|
||||
|
||||
# Sort: newest-ish first. Autotask query ordering isn't guaranteed, so we provide a stable sort.
|
||||
items.sort(key=lambda x: (x.get("ticketNumber") or ""), reverse=True)
|
||||
|
||||
return jsonify({"status": "ok", "items": items})
|
||||
|
||||
|
||||
@main_bp.post("/api/run-checks/autotask-link-existing-ticket")
|
||||
@login_required
|
||||
@roles_required("admin", "operator")
|
||||
def api_run_checks_autotask_link_existing_ticket():
|
||||
"""Link an existing Autotask ticket to the selected run (and propagate to all active runs of the job).
|
||||
|
||||
Phase 2.2: used by the Run Checks modal.
|
||||
"""
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
try:
|
||||
run_id = int(data.get("run_id") or 0)
|
||||
except Exception:
|
||||
run_id = 0
|
||||
|
||||
try:
|
||||
ticket_id = int(data.get("ticket_id") or 0)
|
||||
except Exception:
|
||||
ticket_id = 0
|
||||
|
||||
if run_id <= 0 or ticket_id <= 0:
|
||||
return jsonify({"status": "error", "message": "Invalid parameters."}), 400
|
||||
|
||||
run = JobRun.query.get(run_id)
|
||||
if not run:
|
||||
return jsonify({"status": "error", "message": "Run not found."}), 404
|
||||
|
||||
# Do not overwrite an existing link unless the current one is resolved/deleted.
|
||||
if getattr(run, "autotask_ticket_id", None):
|
||||
return jsonify({"status": "error", "message": "Run already has an Autotask ticket linked."}), 400
|
||||
|
||||
job = Job.query.get(run.job_id)
|
||||
if not job:
|
||||
return jsonify({"status": "error", "message": "Job not found."}), 404
|
||||
|
||||
customer = Customer.query.get(job.customer_id) if getattr(job, "customer_id", None) else None
|
||||
if not customer:
|
||||
return jsonify({"status": "error", "message": "Customer not found."}), 404
|
||||
|
||||
if not getattr(customer, "autotask_company_id", None):
|
||||
return jsonify({"status": "error", "message": "Customer has no Autotask company mapping."}), 400
|
||||
|
||||
if (getattr(customer, "autotask_mapping_status", None) or "").strip().lower() not in ("ok", "renamed"):
|
||||
return jsonify({"status": "error", "message": "Autotask company mapping is not valid."}), 400
|
||||
|
||||
try:
|
||||
client = _build_autotask_client_from_settings()
|
||||
t = client.get_ticket(ticket_id)
|
||||
except Exception as exc:
|
||||
return jsonify({"status": "error", "message": f"Autotask ticket retrieval failed: {exc}"}), 400
|
||||
|
||||
if not isinstance(t, dict):
|
||||
return jsonify({"status": "error", "message": "Autotask did not return a ticket object."}), 400
|
||||
|
||||
# Enforce company scope.
|
||||
try:
|
||||
t_company = int(t.get("companyID") or 0)
|
||||
except Exception:
|
||||
t_company = 0
|
||||
|
||||
if t_company != int(customer.autotask_company_id):
|
||||
return jsonify({"status": "error", "message": "Selected ticket does not belong to the mapped Autotask company."}), 400
|
||||
|
||||
tnum = (t.get("ticketNumber") or t.get("number") or "")
|
||||
tnum = str(tnum or "").strip()
|
||||
if not tnum:
|
||||
return jsonify({"status": "error", "message": "Autotask ticket does not have a ticket number."}), 400
|
||||
|
||||
# Block terminal tickets from being linked (Phase 2.2 only lists open tickets, but enforce server-side).
|
||||
try:
|
||||
st = int(t.get("status")) if t.get("status") is not None else 0
|
||||
except Exception:
|
||||
st = 0
|
||||
if st in AUTOTASK_TERMINAL_STATUS_IDS:
|
||||
return jsonify({"status": "error", "message": "Cannot link a terminal/completed Autotask ticket."}), 400
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
run.autotask_ticket_id = int(ticket_id)
|
||||
run.autotask_ticket_number = tnum
|
||||
run.autotask_ticket_created_at = now
|
||||
run.autotask_ticket_created_by_user_id = current_user.id
|
||||
|
||||
# Propagate linkage to all active (unreviewed) runs of the same job.
|
||||
active_runs = (
|
||||
JobRun.query.filter(JobRun.job_id == job.id, JobRun.reviewed_at.is_(None)).order_by(JobRun.id.asc()).all()
|
||||
)
|
||||
run_ids = []
|
||||
for rr in active_runs or []:
|
||||
if getattr(rr, "id", None) is None:
|
||||
continue
|
||||
rr.autotask_ticket_id = int(ticket_id)
|
||||
rr.autotask_ticket_number = tnum
|
||||
if getattr(rr, "autotask_ticket_created_at", None) is None:
|
||||
rr.autotask_ticket_created_at = now
|
||||
if getattr(rr, "autotask_ticket_created_by_user_id", None) is None:
|
||||
rr.autotask_ticket_created_by_user_id = current_user.id
|
||||
run_ids.append(int(rr.id))
|
||||
|
||||
# Ensure internal Ticket + TicketJobRun linkage for legacy ticket behavior.
|
||||
internal_ticket = None
|
||||
try:
|
||||
internal_ticket = _ensure_internal_ticket_for_autotask(
|
||||
ticket_number=tnum,
|
||||
job=job,
|
||||
run_ids=run_ids,
|
||||
now=now,
|
||||
active_from_dt=now,
|
||||
)
|
||||
except Exception:
|
||||
internal_ticket = None
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
return jsonify({"status": "error", "message": "Failed to persist Autotask ticket link."}), 500
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"ticket_id": int(ticket_id),
|
||||
"ticket_number": tnum,
|
||||
"internal_ticket_id": int(getattr(internal_ticket, "id", 0) or 0) if internal_ticket else 0,
|
||||
}
|
||||
)
|
||||
@main_bp.post("/api/run-checks/mark-reviewed")
|
||||
@login_required
|
||||
@roles_required("admin", "operator")
|
||||
|
||||
@ -219,8 +219,11 @@
|
||||
{% if autotask_enabled %}
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="fw-semibold">Autotask ticket</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="rcm_autotask_link_existing">Link existing</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="rcm_autotask_create">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 small" id="rcm_autotask_info"></div>
|
||||
<div class="mt-2 small text-muted" id="rcm_autotask_status"></div>
|
||||
{% else %}
|
||||
@ -292,6 +295,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="modal fade" id="autotaskLinkModal" tabindex="-1" aria-labelledby="autotaskLinkModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="autotaskLinkModalLabel">Link existing Autotask ticket</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<input type="text" class="form-control" id="atl_search" placeholder="Search by ticket number or title" />
|
||||
<button type="button" class="btn btn-outline-secondary" id="atl_refresh">Refresh</button>
|
||||
</div>
|
||||
<div class="small text-muted mb-2" id="atl_status"></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 1%;"></th>
|
||||
<th>Ticket</th>
|
||||
<th>Title</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="atl_tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="text-muted" id="atl_empty" style="display:none;">No tickets found.</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var table = document.getElementById('runChecksTable');
|
||||
@ -851,6 +891,7 @@ table.addEventListener('change', function (e) {
|
||||
|
||||
function bindInlineCreateForms() {
|
||||
var btnAutotask = document.getElementById('rcm_autotask_create');
|
||||
var btnAutotaskLink = document.getElementById('rcm_autotask_link_existing');
|
||||
var atInfo = document.getElementById('rcm_autotask_info');
|
||||
var atStatus = document.getElementById('rcm_autotask_status');
|
||||
|
||||
@ -870,6 +911,7 @@ table.addEventListener('change', function (e) {
|
||||
|
||||
function setDisabled(disabled) {
|
||||
if (btnAutotask) btnAutotask.disabled = disabled;
|
||||
if (btnAutotaskLink) btnAutotaskLink.disabled = disabled;
|
||||
if (btnTicket) btnTicket.disabled = disabled;
|
||||
if (tCode) tCode.disabled = disabled;
|
||||
if (btnRemark) btnRemark.disabled = disabled;
|
||||
@ -915,6 +957,12 @@ table.addEventListener('change', function (e) {
|
||||
if (run && run.autotask_ticket_id && (isResolved || isDeleted)) btnAutotask.textContent = 'Create new';
|
||||
else btnAutotask.textContent = 'Create';
|
||||
}
|
||||
|
||||
if (btnAutotaskLink) {
|
||||
var hasAtTicket = !!(run && run.autotask_ticket_id);
|
||||
// Link existing is only meaningful when there is no PSA ticket linked to this run.
|
||||
btnAutotaskLink.style.display = hasAtTicket ? 'none' : '';
|
||||
}
|
||||
}
|
||||
window.__rcmRenderAutotaskInfo = renderAutotaskInfo;
|
||||
|
||||
@ -963,6 +1011,114 @@ table.addEventListener('change', function (e) {
|
||||
});
|
||||
}
|
||||
|
||||
if (btnAutotaskLink) {
|
||||
var linkModalEl = document.getElementById('autotaskLinkModal');
|
||||
var linkModal = linkModalEl ? bootstrap.Modal.getOrCreateInstance(linkModalEl) : null;
|
||||
var atlSearch = document.getElementById('atl_search');
|
||||
var atlRefresh = document.getElementById('atl_refresh');
|
||||
var atlStatus = document.getElementById('atl_status');
|
||||
var atlTbody = document.getElementById('atl_tbody');
|
||||
var atlEmpty = document.getElementById('atl_empty');
|
||||
|
||||
function renderAtlRows(items) {
|
||||
if (!atlTbody) return;
|
||||
atlTbody.innerHTML = '';
|
||||
if (atlEmpty) atlEmpty.style.display = (items && items.length) ? 'none' : '';
|
||||
(items || []).forEach(function (t) {
|
||||
var tr = document.createElement('tr');
|
||||
var tdBtn = document.createElement('td');
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-sm btn-outline-primary';
|
||||
btn.textContent = 'Link';
|
||||
btn.addEventListener('click', function () {
|
||||
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||
if (!confirm('Link ticket ' + (t.ticketNumber || '') + ' to this run?')) return;
|
||||
if (atlStatus) atlStatus.textContent = 'Linking...';
|
||||
apiJson('/api/run-checks/autotask-link-existing-ticket', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({run_id: currentRunId, ticket_id: t.id})
|
||||
})
|
||||
.then(function (j) {
|
||||
if (!j || j.status !== 'ok') throw new Error((j && j.message) || 'Failed.');
|
||||
if (atlStatus) atlStatus.textContent = '';
|
||||
if (linkModal) linkModal.hide();
|
||||
|
||||
// Refresh modal data so UI reflects stored ticket linkage.
|
||||
var keepRunId = currentRunId;
|
||||
if (currentJobId) {
|
||||
return fetch('/api/run-checks/details?job_id=' + encodeURIComponent(currentJobId))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (payload) {
|
||||
currentPayload = payload;
|
||||
var idx = 0;
|
||||
var runs = (payload && payload.runs) || [];
|
||||
for (var i = 0; i < runs.length; i++) {
|
||||
if (String(runs[i].id) === String(keepRunId)) { idx = i; break; }
|
||||
}
|
||||
renderRun(payload, idx);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function (e) {
|
||||
if (atlStatus) atlStatus.textContent = e.message || 'Failed.';
|
||||
else alert(e.message || 'Failed.');
|
||||
});
|
||||
});
|
||||
tdBtn.appendChild(btn);
|
||||
|
||||
var tdNum = document.createElement('td');
|
||||
tdNum.textContent = (t.ticketNumber || '');
|
||||
|
||||
var tdTitle = document.createElement('td');
|
||||
tdTitle.textContent = (t.title || '');
|
||||
|
||||
var tdStatus = document.createElement('td');
|
||||
tdStatus.textContent = (t.status_label || String(t.status || ''));
|
||||
|
||||
tr.appendChild(tdBtn);
|
||||
tr.appendChild(tdNum);
|
||||
tr.appendChild(tdTitle);
|
||||
tr.appendChild(tdStatus);
|
||||
atlTbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function loadExistingTickets() {
|
||||
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||
var q = atlSearch ? (atlSearch.value || '').trim() : '';
|
||||
if (atlStatus) atlStatus.textContent = 'Loading...';
|
||||
fetch('/api/run-checks/autotask-existing-tickets?run_id=' + encodeURIComponent(currentRunId) + '&q=' + encodeURIComponent(q))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) {
|
||||
if (!j || j.status !== 'ok') throw new Error((j && j.message) || 'Failed.');
|
||||
if (atlStatus) atlStatus.textContent = '';
|
||||
renderAtlRows(j.items || []);
|
||||
})
|
||||
.catch(function (e) {
|
||||
if (atlStatus) atlStatus.textContent = e.message || 'Failed.';
|
||||
renderAtlRows([]);
|
||||
});
|
||||
}
|
||||
|
||||
if (atlRefresh) {
|
||||
atlRefresh.addEventListener('click', function () { loadExistingTickets(); });
|
||||
}
|
||||
if (atlSearch) {
|
||||
atlSearch.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter') { ev.preventDefault(); loadExistingTickets(); }
|
||||
});
|
||||
}
|
||||
|
||||
btnAutotaskLink.addEventListener('click', function () {
|
||||
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||
if (atlStatus) atlStatus.textContent = '';
|
||||
renderAtlRows([]);
|
||||
if (linkModal) linkModal.show();
|
||||
loadExistingTickets();
|
||||
});
|
||||
}
|
||||
|
||||
if (btnAutotask) {
|
||||
btnAutotask.addEventListener('click', function () {
|
||||
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||
|
||||
@ -422,6 +422,18 @@ Changes:
|
||||
- Preserved legacy internal Ticket and TicketJobRun creation/linking so Tickets overview, Tickets/Remarks, and Job Details continue to function identically to manually linked tickets.
|
||||
- Ensured resolution timestamps are derived from Autotask (resolvedDateTime / completedDate) instead of using current time.
|
||||
|
||||
## v20260120-08-runchecks-link-existing-autotask-ticket
|
||||
|
||||
- Added Phase 2.2 “Link existing Autotask ticket” flow in Run Checks:
|
||||
- New UI button “Link existing” next to “Create” in the Run Checks modal.
|
||||
- Added modal popup with search + refresh and a selectable list of non-terminal tickets.
|
||||
- Added backend API endpoints:
|
||||
- GET /api/run-checks/autotask-existing-tickets (list/search tickets for mapped company, excluding terminal statuses)
|
||||
- POST /api/run-checks/autotask-link-existing-ticket (link selected ticket to run and create/update internal Ticket + TicketJobRun links)
|
||||
- Extended Autotask client with a ticket query helper to support listing/searching tickets for a company.
|
||||
- Improved internal ticket resolve handling:
|
||||
- Do not overwrite resolved_origin when already set; keep “psa” origin when resolved by PSA.
|
||||
|
||||
***
|
||||
|
||||
## v0.1.21
|
||||
|
||||
Loading…
Reference in New Issue
Block a user