From 3ccf8b50cb33844c2c4803852f8b10df1aeb33b0 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Thu, 4 Jun 2026 14:36:31 +0300 Subject: [PATCH] feat(portal): add case management workflow --- adk-rust/crates/detmir-portal/src/main.rs | 470 ++++++++++++++++++ .../crates/detmir-portal/src/static/app.js | 133 ++++- docs/BUSINESS_RISK_RU.md | 54 ++ 3 files changed, 653 insertions(+), 4 deletions(-) diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index a2a96c0..0eb10af 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -382,6 +382,70 @@ struct InvestigationPack { markdown: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +struct CaseItem { + case_id: String, + candidate_id: String, + title: String, + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + owner: Option, + created_at_utc: String, + updated_at_utc: String, + #[serde(skip_serializing_if = "Option::is_none")] + summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + decision: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +struct CaseFile { + cases: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct CreateCaseRequest { + candidate_id: String, + #[serde(default)] + title: Option, + #[serde(default)] + owner: Option, + #[serde(default)] + summary: Option, +} + +#[derive(Debug, Deserialize)] +struct CaseStatusRequest { + status: String, + #[serde(default)] + owner: Option, + #[serde(default)] + summary: Option, + #[serde(default)] + decision: Option, +} + +#[derive(Debug, Serialize)] +struct CaseResponse { + ok: bool, + case: CaseItem, +} + +#[derive(Debug, Serialize)] +struct CaseListResponse { + ok: bool, + cases: Vec, +} + +#[derive(Debug, Serialize)] +struct CaseDetailsResponse { + ok: bool, + case: CaseItem, + #[serde(skip_serializing_if = "Option::is_none")] + investigation_pack: Option, + markdown: String, +} + #[derive(Debug, Serialize)] struct HealthResponse { ok: bool, @@ -939,6 +1003,14 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) if method == Method::Post && path == "/api/incident-review" { return handle_incident_review(request, args); } + if method == Method::Post && path == "/api/cases" { + return handle_create_case(request, args, snapshot_cache); + } + if method == Method::Post { + if let Some(case_id) = parse_case_status_path(&path) { + return handle_case_status(request, args, &case_id); + } + } if method == Method::Post && path == "/api/telemetry" { return handle_telemetry_ingest(request, args); } @@ -951,6 +1023,9 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) if let Some(candidate_id) = parse_investigation_pack_path(&path) { return handle_investigation_pack(request, args, snapshot_cache, &url, &candidate_id); } + if let Some(case_id) = parse_case_path(&path) { + return handle_case_details(request, args, snapshot_cache, &url, &case_id); + } if let Some((evidence_id, download)) = parse_evidence_screenshot_path(&path) { return handle_evidence_screenshot(request, args, &evidence_id, download); } @@ -1029,6 +1104,7 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) let incident_state = load_incident_state_best_effort(args); respond_json(request, &build_incidents(&snapshot, &incident_state)) } + "/api/cases" => respond_json(request, &build_case_list(args)), "/api/links" => respond_json(request, &links()), _ => respond_text( request, @@ -1234,6 +1310,21 @@ fn parse_investigation_pack_path(path: &str) -> Option { .map(ToString::to_string) } +fn parse_case_path(path: &str) -> Option { + path.strip_prefix("/api/cases/") + .map(str::trim) + .filter(|value| !value.is_empty() && !value.contains('/')) + .map(ToString::to_string) +} + +fn parse_case_status_path(path: &str) -> Option { + path.strip_prefix("/api/cases/") + .and_then(|value| value.strip_suffix("/status")) + .map(str::trim) + .filter(|value| !value.is_empty() && !value.contains('/')) + .map(ToString::to_string) +} + fn cached_snapshot(args: &Cli, cache: &SnapshotCache) -> Snapshot { let mut guard = cache.lock().expect("snapshot cache mutex poisoned"); if let Some(cached) = guard.as_ref() { @@ -5591,6 +5682,84 @@ fn handle_investigation_pack( } } +fn handle_create_case( + mut request: Request, + args: &Cli, + snapshot_cache: &SnapshotCache, +) -> Result<()> { + let mut body = String::new(); + request + .as_reader() + .take(32 * 1024) + .read_to_string(&mut body)?; + match apply_create_case(args, &cached_snapshot(args, snapshot_cache), &body) { + Ok(response) => respond_json(request, &response), + Err(err) => respond_json_status( + request, + StatusCode(400), + &json!({ + "ok": false, + "error": err.to_string() + }), + ), + } +} + +fn handle_case_status(mut request: Request, args: &Cli, case_id: &str) -> Result<()> { + let mut body = String::new(); + request + .as_reader() + .take(32 * 1024) + .read_to_string(&mut body)?; + match apply_case_status(args, case_id, &body) { + Ok(response) => respond_json(request, &response), + Err(err) => respond_json_status( + request, + StatusCode(400), + &json!({ + "ok": false, + "error": err.to_string() + }), + ), + } +} + +fn handle_case_details( + request: Request, + args: &Cli, + snapshot_cache: &SnapshotCache, + url: &str, + case_id: &str, +) -> Result<()> { + match build_case_details(args, &cached_snapshot(args, snapshot_cache), case_id) { + Ok(details) => { + let format = query_param(url, "format") + .unwrap_or_else(|| "json".to_string()) + .to_ascii_lowercase(); + if matches!(format.as_str(), "md" | "markdown") { + let filename = format!("case-{}.md", safe_download_stem(&details.case.case_id)); + respond_text_download( + request, + StatusCode(200), + &details.markdown, + "text/markdown; charset=utf-8", + &filename, + ) + } else { + respond_json(request, &details) + } + } + Err(err) => respond_json_status( + request, + StatusCode(404), + &json!({ + "ok": false, + "error": err.to_string() + }), + ), + } +} + fn handle_telemetry_ingest(mut request: Request, args: &Cli) -> Result<()> { if !telemetry_authorized(&request, args) { return respond_json_status( @@ -5803,6 +5972,179 @@ fn apply_incident_review(args: &Cli, actor: &str, body: &str) -> Result Result { + let request: CreateCaseRequest = + serde_json::from_str(body).map_err(|err| anyhow!("invalid case JSON: {err}"))?; + let candidate_id = validate_short_token(&request.candidate_id, "candidate_id", 128)?; + let incident_reviews = load_incident_review_best_effort(args); + let incident_review_audit = load_incident_review_audit_best_effort(args); + let pack = build_investigation_pack( + snapshot, + &candidate_id, + &incident_reviews, + &incident_review_audit, + )?; + if pack.current_review_status != "CONFIRMED" { + return Err(anyhow!("case can be created only from CONFIRMED candidate")); + } + + let mut case_file = load_cases(args)?; + if let Some(existing) = case_file + .cases + .values() + .find(|item| item.candidate_id == candidate_id && item.status != "ARCHIVED") + .cloned() + { + return Ok(CaseResponse { + ok: true, + case: existing, + }); + } + + let now = now(); + let title = sanitize_optional_text(request.title, 160) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| format!("Дело по кандидату {}", pack.candidate_id)); + let owner = sanitize_optional_text(request.owner, 80) + .or_else(|| pack.owner.clone()) + .filter(|value| !value.is_empty()); + let summary = sanitize_optional_text(request.summary, 1000) + .or_else(|| pack.reasons.first().cloned()) + .filter(|value| !value.is_empty()); + let case_id = unique_case_id(&case_file, &pack.candidate_id, &now); + let case = CaseItem { + case_id: case_id.clone(), + candidate_id: pack.candidate_id, + title, + status: "OPEN".to_string(), + owner, + created_at_utc: now.clone(), + updated_at_utc: now, + summary, + decision: None, + }; + case_file.cases.insert(case_id, case.clone()); + save_cases(args, &case_file)?; + Ok(CaseResponse { ok: true, case }) +} + +fn apply_case_status(args: &Cli, case_id: &str, body: &str) -> Result { + let case_id = validate_short_token(case_id, "case_id", 128)?; + let request: CaseStatusRequest = + serde_json::from_str(body).map_err(|err| anyhow!("invalid case status JSON: {err}"))?; + let status = validate_case_status(&request.status)?; + let mut case_file = load_cases(args)?; + let Some(case) = case_file.cases.get_mut(&case_id) else { + return Err(anyhow!("case not found: {case_id}")); + }; + case.status = status.to_string(); + if let Some(owner) = sanitize_optional_text(request.owner, 80) { + case.owner = (!owner.is_empty()).then_some(owner); + } + if let Some(summary) = sanitize_optional_text(request.summary, 1000) { + case.summary = (!summary.is_empty()).then_some(summary); + } + if let Some(decision) = sanitize_optional_text(request.decision, 1000) { + case.decision = (!decision.is_empty()).then_some(decision); + } + case.updated_at_utc = now(); + let case = case.clone(); + save_cases(args, &case_file)?; + Ok(CaseResponse { ok: true, case }) +} + +fn build_case_list(args: &Cli) -> CaseListResponse { + let mut cases = load_cases_best_effort(args) + .cases + .into_values() + .collect::>(); + cases.sort_by(|left, right| { + right + .updated_at_utc + .cmp(&left.updated_at_utc) + .then_with(|| left.case_id.cmp(&right.case_id)) + }); + CaseListResponse { ok: true, cases } +} + +fn build_case_details( + args: &Cli, + snapshot: &Snapshot, + case_id: &str, +) -> Result { + let case_id = validate_short_token(case_id, "case_id", 128)?; + let case_file = load_cases(args)?; + let case = case_file + .cases + .get(&case_id) + .cloned() + .ok_or_else(|| anyhow!("case not found: {case_id}"))?; + let incident_reviews = load_incident_review_best_effort(args); + let incident_review_audit = load_incident_review_audit_best_effort(args); + let investigation_pack = build_investigation_pack( + snapshot, + &case.candidate_id, + &incident_reviews, + &incident_review_audit, + ) + .ok(); + let markdown = render_case_markdown(&case, investigation_pack.as_ref()); + Ok(CaseDetailsResponse { + ok: true, + case, + investigation_pack, + markdown, + }) +} + +fn unique_case_id(case_file: &CaseFile, candidate_id: &str, timestamp: &str) -> String { + let mut case_id = incident_id("case", candidate_id, timestamp); + let mut counter = 1usize; + while case_file.cases.contains_key(&case_id) { + counter += 1; + case_id = incident_id("case", candidate_id, &format!("{timestamp}:{counter}")); + } + case_id +} + +fn render_case_markdown(case: &CaseItem, pack: Option<&InvestigationPack>) -> String { + let mut text = String::new(); + text.push_str("# Карточка дела\n\n"); + text.push_str("## Сведения о деле\n\n"); + text.push_str(&format!("- Дело: {}\n", case.case_id)); + text.push_str(&format!("- Кандидат: {}\n", case.candidate_id)); + text.push_str(&format!("- Название: {}\n", case.title)); + text.push_str(&format!("- Статус: {}\n", case.status)); + text.push_str(&format!( + "- Ответственный: {}\n", + case.owner.as_deref().unwrap_or("-") + )); + text.push_str(&format!("- Создано: {}\n", case.created_at_utc)); + text.push_str(&format!("- Обновлено: {}\n", case.updated_at_utc)); + text.push_str(&format!( + "- Резюме: {}\n", + case.summary.as_deref().unwrap_or("-") + )); + text.push_str(&format!( + "- Решение: {}\n", + case.decision.as_deref().unwrap_or("-") + )); + text.push_str("\n## Пакет расследования\n\n"); + if let Some(pack) = pack { + text.push_str(&pack.markdown); + } else { + text.push_str("- Пакет расследования недоступен для текущего кандидата.\n"); + } + text.push_str("\n## Решение по делу\n\n"); + text.push_str( + case.decision + .as_deref() + .unwrap_or("Решение пока не зафиксировано. Дело требует ручного рассмотрения."), + ); + text.push('\n'); + text +} + fn load_incident_state_best_effort(args: &Cli) -> IncidentStateFile { match load_incident_state(args) { Ok(state) => state, @@ -5833,6 +6175,16 @@ fn load_incident_review_audit_best_effort(args: &Cli) -> Vec CaseFile { + match load_cases(args) { + Ok(state) => state, + Err(err) => { + eprintln!("detmir-portal cases read failed: {err:#}"); + CaseFile::default() + } + } +} + fn load_incident_state(args: &Cli) -> Result { let path = incident_state_path(args); if !path.exists() { @@ -5870,6 +6222,15 @@ fn load_incident_review_audit(args: &Cli) -> Result Result { + let path = cases_path(args); + if !path.exists() { + return Ok(CaseFile::default()); + } + let data = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + serde_json::from_str(&data).with_context(|| format!("parse {}", path.display())) +} + fn save_incident_state(args: &Cli, state: &IncidentStateFile) -> Result<()> { fs::create_dir_all(&args.state_dir) .with_context(|| format!("create {}", args.state_dir.display()))?; @@ -5881,6 +6242,18 @@ fn save_incident_state(args: &Cli, state: &IncidentStateFile) -> Result<()> { Ok(()) } +fn save_cases(args: &Cli, state: &CaseFile) -> Result<()> { + let path = cases_path(args); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, serde_json::to_vec_pretty(state)?) + .with_context(|| format!("write {}", tmp.display()))?; + fs::rename(&tmp, &path).with_context(|| format!("rename {}", path.display()))?; + Ok(()) +} + fn save_incident_review(args: &Cli, state: &IncidentReviewFile) -> Result<()> { let path = incident_review_path(args); if let Some(parent) = path.parent() { @@ -5936,6 +6309,10 @@ fn incident_review_audit_path(args: &Cli) -> PathBuf { .join("incident_review_audit.jsonl") } +fn cases_path(args: &Cli) -> PathBuf { + args.state_dir.join("data").join("cases.json") +} + fn build_dlp_evidence_response(args: &Cli) -> DlpEvidenceResponse { let generated_at_utc = now(); let db_available = args.dlp_db_path.exists(); @@ -6696,6 +7073,17 @@ fn validate_incident_review_status(value: &str) -> Result<&'static str> { } } +fn validate_case_status(value: &str) -> Result<&'static str> { + match value.trim() { + "OPEN" => Ok("OPEN"), + "IN_PROGRESS" => Ok("IN_PROGRESS"), + "RESOLVED" => Ok("RESOLVED"), + "REJECTED" => Ok("REJECTED"), + "ARCHIVED" => Ok("ARCHIVED"), + _ => Err(anyhow!("unsupported case status")), + } +} + fn sanitize_optional_text(value: Option, max_len: usize) -> Option { value .map(|text| sanitize_text(&text, max_len)) @@ -8400,6 +8788,88 @@ mod tests { assert!(pack.trust_kpi_snapshot.is_object()); assert!(pack.agent_quality_snapshot.is_object()); assert!(pack.business_risk_snapshot.is_object()); + let case_dir = tempfile::tempdir().unwrap(); + let case_args = Cli { + bind: "127.0.0.1:0".to_string(), + status_cmd: "true".to_string(), + check_cmd: "true".to_string(), + failed_units_cmd: "true".to_string(), + worktime_url: "http://127.0.0.1".to_string(), + one_c_url: "http://127.0.0.1".to_string(), + workforce_policy_path: case_dir.path().join("workforce-policy.json"), + ueba_policy_path: case_dir.path().join("ueba-policy.yaml"), + timeout_seconds: 1, + state_dir: case_dir.path().join("state"), + dlp_db_path: case_dir.path().join("dlp.sqlite"), + evidence_root: case_dir.path().to_path_buf(), + readiness_bundle_dir: case_dir.path().join("readiness-bundle"), + evidence_limit: 10, + evidence_max_bytes: 1024, + json_smoke: false, + evidence_only: false, + evidence_upload_token: None, + telemetry_api_key: "test-key".to_string(), + telemetry_store_path: case_dir.path().join("telemetry.jsonl"), + expected_nodes_path: case_dir.path().join("expected_nodes.json"), + }; + assert!( + apply_create_case( + &case_args, + &snapshot, + &json!({ + "candidate_id": candidate_id, + "title": "Case must fail before confirmation" + }) + .to_string() + ) + .is_err() + ); + let mut confirmed_reviews = IncidentReviewFile::default(); + confirmed_reviews.reviews.insert( + candidate_id.to_string(), + IncidentReviewState { + candidate_id: candidate_id.to_string(), + status: "CONFIRMED".to_string(), + reviewer: Some("operator".to_string()), + comment: Some("confirmed for case".to_string()), + updated_at: "2026-06-04T12:00:00Z".to_string(), + }, + ); + save_incident_review(&case_args, &confirmed_reviews).unwrap(); + let case_response = apply_create_case( + &case_args, + &snapshot, + &json!({ + "candidate_id": candidate_id, + "title": "Проверка KPI", + "owner": "operator", + "summary": "manual case" + }) + .to_string(), + ) + .unwrap(); + assert_eq!(case_response.case.status, "OPEN"); + assert!(cases_path(&case_args).ends_with("data/cases.json")); + let list = build_case_list(&case_args); + assert_eq!(list.cases.len(), 1); + let status_response = apply_case_status( + &case_args, + &case_response.case.case_id, + &json!({ + "status": "RESOLVED", + "decision": "issue resolved" + }) + .to_string(), + ) + .unwrap(); + assert_eq!(status_response.case.status, "RESOLVED"); + let details = + build_case_details(&case_args, &snapshot, &case_response.case.case_id).unwrap(); + assert!(details.investigation_pack.is_some()); + assert!(details.markdown.contains("# Карточка дела")); + assert!(details.markdown.contains("## Пакет расследования")); + assert_eq!(validate_case_status("ARCHIVED").unwrap(), "ARCHIVED"); + assert!(validate_case_status("AUTO").is_err()); assert!( build_investigation_pack( &snapshot, diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index 9d123af..6a7b1f3 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -23,9 +23,9 @@ async function postJson(path, payload) { function statusClass(status) { const s = String(status || "UNKNOWN").toLowerCase(); - if (s === "ok" || s === "true" || s === "low" || s === "false_positive") return "status-ok"; - if (s === "warn" || s === "warning" || s === "fallback" || s === "stale" || s === "medium" || s === "in_review" || s === "postponed") return "status-warn"; - if (s === "degraded" || s === "high" || s === "confirmed") return "status-degraded"; + if (s === "ok" || s === "true" || s === "low" || s === "false_positive" || s === "resolved") return "status-ok"; + if (s === "warn" || s === "warning" || s === "fallback" || s === "stale" || s === "medium" || s === "in_review" || s === "postponed" || s === "open" || s === "in_progress") return "status-warn"; + if (s === "degraded" || s === "high" || s === "confirmed" || s === "rejected" || s === "archived") return "status-degraded"; if (s === "fail" || s === "false" || s === "error" || s === "critical" || s === "missing") return "status-fail"; return "status-unknown"; } @@ -1280,6 +1280,7 @@ function renderIncidents(data) { const incidents = Array.isArray(data) ? data : data.incidents; const evidence = Array.isArray(data) ? null : data.evidence; const reports = Array.isArray(data) ? state.reports : data.reports; + const cases = Array.isArray(data?.cases?.cases) ? data.cases.cases : []; return `
@@ -1299,6 +1300,7 @@ function renderIncidents(data) {
${renderAutoInvestigationCard(reports)} + ${renderCases(cases)}

Материалы: скриншоты, хеши, файлы

${renderDlpEvidence(evidence)} @@ -1306,6 +1308,79 @@ function renderIncidents(data) { `; } +function renderCases(cases) { + const rows = Array.isArray(cases) ? cases.slice(0, 20) : []; + return ` +
+
+
+

Дела

+

Ручные дела, созданные из подтвержденных кандидатов. Автоматически дела не создаются.

+
+ ${rows.length} +
+
+ + + + + + + + + + + + + ${rows.length ? rows.map(item => ` + + + + + + + + + `).join("") : ` + + + + + + + + + `} + +
ДелоКандидатСтатусОтветственныйРешениеДействие
${ui(item.title || item.case_id)}
${ui(item.case_id || "-")}
${ui(item.created_at_utc || "-")} · ${ui(item.updated_at_utc || "-")}
${ui(item.candidate_id || "-")}
${ui(item.summary || "резюме не задано")}
${ui(caseStatusText(item.status))}${ui(item.owner || "ответственный не указан")}${ui(item.decision || "решение не зафиксировано")}${renderCaseActions(item)}
Нет дел-OK-Создайте дело из подтвержденного кандидата.-
+
+
+ `; +} + +function renderCaseActions(item) { + const id = item?.case_id || ""; + const packUrl = `/portal/api/cases/${encodeURIComponent(id)}?format=markdown`; + return ` +
+ + + + +
+ Скачать карточку дела + `; +} + +function caseStatusText(status) { + const value = String(status || "OPEN").toUpperCase(); + if (value === "IN_PROGRESS") return "В работе"; + if (value === "RESOLVED") return "Решено"; + if (value === "REJECTED") return "Отклонено"; + if (value === "ARCHIVED") return "Архив"; + return "Открыто"; +} + function renderKpiCards(items) { return `
${(items || []).map(item => `
@@ -1805,6 +1880,7 @@ function renderCandidateReview(item) { function renderCandidateReviewActions(item) { const id = item?.id || ""; + const reviewStatus = String(item?.incident_review?.status || "NEW").toUpperCase(); const actions = [ ["IN_REVIEW", "В проверку"], ["CONFIRMED", "Подтвердить"], @@ -1812,11 +1888,15 @@ function renderCandidateReviewActions(item) { ["POSTPONED", "Отложить"], ]; const packUrl = `/portal/api/investigation-pack/${encodeURIComponent(id)}?format=markdown`; + const createCase = reviewStatus === "CONFIRMED" + ? `` + : ""; return `
${actions.map(([status, label]) => ` `).join("")}
Скачать пакет расследования + ${createCase} `; } @@ -1986,8 +2066,9 @@ async function refresh() { const data = await loadJson("/incidents"); const evidence = await loadJson("/dlp/evidence").catch(error => ({ ok: false, error: error.message, items: [] })); const reports = await loadJson("/reports").catch(() => state.reports || {}); + const cases = await loadJson("/cases").catch(error => ({ ok: false, error: error.message, cases: [] })); state.reports = reports; - content.innerHTML = renderIncidents({ incidents: data, evidence, reports }); + content.innerHTML = renderIncidents({ incidents: data, evidence, reports, cases }); } if (state.tab === "perimeter") { const data = await loadJson("/owner"); @@ -2062,6 +2143,18 @@ document.addEventListener("click", event => { candidateReviewAction(button).catch(showError); }); +document.addEventListener("click", event => { + const button = event.target.closest("[data-create-case]"); + if (!button) return; + createCaseAction(button).catch(showError); +}); + +document.addEventListener("click", event => { + const button = event.target.closest("[data-case-status]"); + if (!button) return; + caseStatusAction(button).catch(showError); +}); + document.addEventListener("click", event => { const button = event.target.closest("[data-print-report]"); if (!button) return; @@ -2175,6 +2268,38 @@ async function candidateReviewAction(button) { await refresh(); } +async function createCaseAction(button) { + const candidateId = button.dataset.candidateId; + const title = window.prompt("Название дела", `Дело по кандидату ${candidateId}`); + if (title === null || title.trim() === "") return; + const owner = window.prompt("Ответственный по делу", ""); + if (owner === null) return; + const summary = window.prompt("Краткое резюме дела", ""); + if (summary === null) return; + button.disabled = true; + await postJson("/cases", { + candidate_id: candidateId, + title, + owner, + summary, + }); + state.tab = "incidents"; + await refresh(); +} + +async function caseStatusAction(button) { + const caseId = button.dataset.caseId; + const status = button.dataset.caseStatus; + const decision = window.prompt("Решение или комментарий по делу", ""); + if (decision === null) return; + button.disabled = true; + await postJson(`/cases/${encodeURIComponent(caseId)}/status`, { + status, + decision, + }); + await refresh(); +} + applySecurityMode(state.tab); refresh().catch(showError); setInterval(() => refresh().catch(showError), 60000); diff --git a/docs/BUSINESS_RISK_RU.md b/docs/BUSINESS_RISK_RU.md index 9d2eca4..d01e5a0 100644 --- a/docs/BUSINESS_RISK_RU.md +++ b/docs/BUSINESS_RISK_RU.md @@ -222,6 +222,60 @@ Markdown-версия содержит разделы: Кнопка `Скачать пакет расследования` выгружает Markdown-пакет по выбранному кандидату без создания инцидента. +## Case Management + +Подтвержденный кандидат можно вручную перевести в дело. Дело не создается +автоматически: оператор должен нажать `Создать дело` у кандидата со статусом +`CONFIRMED`. + +Сущность `case`: + +- `case_id`; +- `candidate_id`; +- `title`; +- `status`; +- `owner`; +- `created_at_utc`; +- `updated_at_utc`; +- `summary`; +- `decision`. + +Статусы: + +```text +OPEN +IN_PROGRESS +RESOLVED +REJECTED +ARCHIVED +``` + +API: + +```http +POST /api/cases +GET /api/cases +GET /api/cases/{case_id} +GET /api/cases/{case_id}?format=markdown +POST /api/cases/{case_id}/status +``` + +Хранение: + +```text +/data/cases.json +``` + +Правила: + +- дело создается только из `CONFIRMED` кандидата; +- повторное создание для того же кандидата возвращает существующее активное + дело, если оно не архивировано; +- дело содержит ссылку на кандидата, решение и текущий Investigation Pack; +- Markdown-экспорт карточки дела включает пакет расследования, статус + проверки, audit review и решение по делу; +- создание/смена статуса дела не создает обычный инцидент автоматически. + ## Markdown Оперативный отчет содержит раздел: