Skip to main content

teamcraft/services/
simulations.rs

1//! Simulation management (FEA, CFD, …).
2//!
3//! A **simulation** is an item of type `simulation` with the usual
4//! lifecycle, revisions, check-out/check-in and change orders. Its files are
5//! the model, the solver deck, selected results, the report and pictures. On
6//! top of that a simulation revision has
7//!
8//! * **basis links** – the item revisions it was computed with and,
9//!   optionally, the exact file version (role geometry, mesh, material, load
10//!   case or other). They belong to the simulation revision like BOM lines:
11//!   changed only while it is checked out, copied by Revise and frozen at
12//!   release;
13//! * **result values** – key numbers such as the maximum stress or the
14//!   pressure drop with unit, optional limits (pass/fail), load case and
15//!   solver. They are entered in the UI, imported from a CSV/Excel table or
16//!   written by a solver script through the REST API, and can be compared
17//!   between two revisions.
18//!
19//! A simulation is **out of date** when a newer revision of a basis item was
20//! released, the basis revision became obsolete or the simulated file has a
21//! newer version. Releasing or obsoleting a basis also flags the simulation
22//! for a check through the change propagation (`impacts`, relation
23//! `simulation`); acknowledging that check means "still valid, because …".
24
25use std::collections::HashMap;
26
27use serde::Serialize;
28use serde_json::json;
29use sqlx::{PgConnection, PgPool};
30use uuid::Uuid;
31
32use crate::{
33    auth::Actor,
34    bomtable,
35    domain::{self, BasisRole, ItemType, ResultStatus, WebhookEvent},
36    error::{AppError, AppResult},
37    models::Revision,
38    services::{access, audit, bom_compare::Change, items, revisions, webhooks},
39};
40
41/// At most this many result values per simulation revision.
42pub const MAX_RESULTS: usize = 2000;
43
44// ---------------------------------------------------------------------------
45// Basis links
46// ---------------------------------------------------------------------------
47
48/// A revision (and file version) a simulation revision is based on.
49#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
50pub struct SimulationBasis {
51    pub id: Uuid,
52    /// The simulation revision.
53    pub revision_id: Uuid,
54    pub role: String,
55    pub note: String,
56    pub basis_item_id: Uuid,
57    pub basis_number: String,
58    pub basis_type: String,
59    pub basis_revision_id: Uuid,
60    pub basis_code: String,
61    #[serde(skip)]
62    pub basis_seq: i32,
63    pub basis_state: String,
64    pub basis_name: String,
65    /// The simulated file version, if one was chosen.
66    pub file_version_id: Option<Uuid>,
67    pub file_id: Option<Uuid>,
68    pub file_name: String,
69    pub file_version: Option<i32>,
70    /// The current version of that file now.
71    pub file_current_version: Option<i32>,
72    /// Newest released revision of the basis item.
73    pub released_revision_id: Option<Uuid>,
74    pub released_code: Option<String>,
75    #[serde(skip)]
76    pub released_seq: Option<i32>,
77    pub created_at: chrono::DateTime<chrono::Utc>,
78    /// Why the simulation no longer matches this basis (`None` = up to date).
79    #[sqlx(default)]
80    pub out_of_date: Option<String>,
81    /// Project group of the basis item (for the read check; never sent).
82    #[serde(skip)]
83    pub basis_project_id: Option<Uuid>,
84    /// Placeholder for a basis the reader may not see: the role stays; the
85    /// identity, the note and whether it is out of date are removed.
86    #[sqlx(default)]
87    pub restricted: bool,
88}
89
90impl SimulationBasis {
91    fn mask(&mut self) {
92        let label = access::RESTRICTED_LABEL;
93        self.basis_item_id = Uuid::nil();
94        self.basis_number = label.into();
95        self.basis_type = String::new();
96        self.basis_revision_id = Uuid::nil();
97        self.basis_code = String::new();
98        self.basis_state = String::new();
99        self.basis_name = String::new();
100        self.file_version_id = None;
101        self.file_id = None;
102        self.file_name = String::new();
103        self.file_version = None;
104        self.file_current_version = None;
105        self.released_revision_id = None;
106        self.released_code = None;
107        // written about the hidden basis, possibly by a member
108        self.note = String::new();
109        // computed from the hidden item (a release, a file check-in,
110        // obsolescence): polling it would tell when the hidden item changes
111        self.out_of_date = None;
112        self.basis_project_id = None;
113        self.restricted = true;
114    }
115
116    /// Masks the basis when the actor may not see it.
117    fn redact(mut self, actor: &Actor) -> Self {
118        if !actor.can_see_project(self.basis_project_id) {
119            self.mask();
120        }
121        self
122    }
123}
124
125/// Fails with "not found" unless the actor may read the revision.
126async fn ensure_revision_visible(pool: &PgPool, actor: &Actor, revision_id: Uuid) -> AppResult<()> {
127    revisions::get(pool, actor, revision_id).await.map(|_| ())
128}
129
130const BASIS_SELECT: &str = "
131    SELECT sb.id, sb.revision_id, sb.role, sb.note,
132           bi.id AS basis_item_id, bi.number AS basis_number, bi.item_type AS basis_type,
133           br.id AS basis_revision_id, br.code AS basis_code, br.seq AS basis_seq,
134           br.state AS basis_state, br.name AS basis_name,
135           sb.file_version_id, f.id AS file_id, sb.file_name, sb.file_version,
136           f.current_version AS file_current_version,
137           lr.id AS released_revision_id, lr.code AS released_code, lr.seq AS released_seq,
138           sb.created_at, bi.project_id AS basis_project_id
139    FROM simulation_bases sb
140    JOIN revisions br ON br.id = sb.basis_revision_id
141    JOIN items bi ON bi.id = br.item_id
142    LEFT JOIN file_versions fv ON fv.id = sb.file_version_id
143    LEFT JOIN files f ON f.id = fv.file_id
144    LEFT JOIN LATERAL (
145        SELECT r.id, r.code, r.seq FROM revisions r
146        WHERE r.item_id = bi.id AND r.released_at IS NOT NULL
147        ORDER BY r.seq DESC LIMIT 1
148    ) lr ON TRUE";
149
150/// What makes a basis out of date, from its stored facts (pure, see the
151/// module docs). `None` means the simulation still matches the basis.
152pub fn out_of_date_reason(b: &SimulationBasis) -> Option<String> {
153    if b.basis_state == "obsolete" {
154        return Some(format!("{}/{} is obsolete", b.basis_number, b.basis_code));
155    }
156    if let (Some(seq), Some(code)) = (b.released_seq, b.released_code.as_deref()) {
157        if seq > b.basis_seq {
158            return Some(format!(
159                "{}/{code} was released (simulated: {})",
160                b.basis_number, b.basis_code
161            ));
162        }
163    }
164    match (b.file_version, b.file_version_id, b.file_current_version) {
165        (Some(v), Some(_), Some(current)) if current > v => Some(format!(
166            "{} has version {current} (simulated: v{v})",
167            b.file_name
168        )),
169        (Some(v), None, _) => Some(format!(
170            "the simulated version v{v} of {} was deleted",
171            b.file_name
172        )),
173        _ => None,
174    }
175}
176
177fn with_status(mut b: SimulationBasis) -> SimulationBasis {
178    b.out_of_date = out_of_date_reason(&b);
179    b
180}
181
182/// The basis links of a simulation revision the actor may read; bases the
183/// actor may not see are placeholders.
184pub async fn bases(
185    pool: &PgPool,
186    actor: &Actor,
187    revision_id: Uuid,
188) -> AppResult<Vec<SimulationBasis>> {
189    ensure_revision_visible(pool, actor, revision_id).await?;
190    let mut rows: Vec<SimulationBasis> = sqlx::query_as::<_, SimulationBasis>(&format!(
191        "{BASIS_SELECT} WHERE sb.revision_id = $1 ORDER BY sb.role, bi.number, sb.file_name"
192    ))
193    .bind(revision_id)
194    .fetch_all(pool)
195    .await?
196    .into_iter()
197    .map(|b| with_status(b).redact(actor))
198    .collect();
199    // placeholders last in their role: their position would bound the
200    // hidden number
201    rows.sort_by(|a, b| (&a.role, a.restricted).cmp(&(&b.role, b.restricted)));
202    Ok(rows)
203}
204
205pub async fn get_basis(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<SimulationBasis> {
206    Ok(get_basis_unmasked(pool, actor, id).await?.redact(actor))
207}
208
209/// [`get_basis`] without the placeholder (for the audit trail of a write).
210async fn get_basis_unmasked(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<SimulationBasis> {
211    let b = sqlx::query_as::<_, SimulationBasis>(&format!("{BASIS_SELECT} WHERE sb.id = $1"))
212        .bind(id)
213        .fetch_optional(pool)
214        .await?
215        .map(with_status)
216        .ok_or_else(|| AppError::not_found("Basis link not found."))?;
217    ensure_revision_visible(pool, actor, b.revision_id)
218        .await
219        .map_err(|_| AppError::not_found("Basis link not found."))?;
220    Ok(b)
221}
222
223/// A simulation based on a revision of an item (the other side of a basis
224/// link), shown on the part or assembly.
225#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
226pub struct BasedSimulation {
227    pub simulation_item_id: Uuid,
228    pub simulation_number: String,
229    pub simulation_code: String,
230    pub simulation_state: String,
231    pub simulation_name: String,
232    /// Result values of the simulation revision and how many failed.
233    pub result_count: i64,
234    pub failed_count: i64,
235    /// An open check (impact) of the simulation against this item.
236    pub open_impact_id: Option<Uuid>,
237    /// The link itself; `basis.revision_id` is the simulation revision.
238    #[sqlx(flatten)]
239    pub basis: SimulationBasis,
240}
241
242/// Simulations whose latest revision is based on a revision of the item.
243/// Only simulations the actor may see; the second value counts the others.
244pub async fn based_on_item(
245    pool: &PgPool,
246    actor: &Actor,
247    item_id: Uuid,
248) -> AppResult<(Vec<BasedSimulation>, i64)> {
249    let all = based_on_item_unchecked(pool, item_id).await?;
250    let ids: Vec<Uuid> = all.iter().map(|s| s.simulation_item_id).collect();
251    let visible: std::collections::HashSet<Uuid> = sqlx::query_scalar::<_, Uuid>(&format!(
252        "SELECT i.id FROM items i WHERE i.id = ANY($1) AND {}",
253        access::item_visible("i", 2)
254    ))
255    .bind(&ids)
256    .bind(&actor.hidden_projects)
257    .fetch_all(pool)
258    .await?
259    .into_iter()
260    .collect();
261    let hidden = all
262        .iter()
263        .filter(|s| !visible.contains(&s.simulation_item_id))
264        .map(|s| s.simulation_item_id)
265        .collect::<std::collections::HashSet<_>>()
266        .len() as i64;
267    Ok((
268        all.into_iter()
269            .filter(|s| visible.contains(&s.simulation_item_id))
270            .collect(),
271        hidden,
272    ))
273}
274
275async fn based_on_item_unchecked(pool: &PgPool, item_id: Uuid) -> AppResult<Vec<BasedSimulation>> {
276    let rows = sqlx::query_as::<_, BasedSimulation>(&format!(
277        "SELECT s.*, si.id AS simulation_item_id, si.number AS simulation_number,
278                sr.code AS simulation_code, sr.state AS simulation_state, sr.name AS simulation_name,
279                (SELECT COUNT(*) FROM simulation_results x WHERE x.revision_id = sr.id) AS result_count,
280                (SELECT COUNT(*) FROM simulation_results x WHERE x.revision_id = sr.id
281                   AND ((x.lower_limit IS NOT NULL AND x.value < x.lower_limit)
282                     OR (x.upper_limit IS NOT NULL AND x.value > x.upper_limit))) AS failed_count,
283                (SELECT im.id FROM impacts im WHERE im.item_id = si.id AND im.source_item_id = $1
284                   AND im.resolved_at IS NULL LIMIT 1) AS open_impact_id
285         FROM ({BASIS_SELECT} WHERE br.item_id = $1) s
286         JOIN revisions sr ON sr.id = s.revision_id
287         JOIN items si ON si.id = sr.item_id
288         WHERE sr.seq = (SELECT MAX(x.seq) FROM revisions x WHERE x.item_id = sr.item_id)
289         ORDER BY si.number, s.role, s.file_name"
290    ))
291    .bind(item_id)
292    .fetch_all(pool)
293    .await?;
294    Ok(rows
295        .into_iter()
296        .map(|mut s| {
297            s.basis = with_status(s.basis);
298            s
299        })
300        .collect())
301}
302
303/// Fails unless the revision belongs to a simulation item.
304pub(crate) fn ensure_simulation(rev: &Revision) -> AppResult<()> {
305    if rev.item_type == ItemType::Simulation.as_str() {
306        Ok(())
307    } else {
308        Err(AppError::BadRequest(format!(
309            "{} is not a simulation; basis links and result values belong to items of type Simulation.",
310            revisions::label(rev)
311        )))
312    }
313}
314
315#[derive(Clone, Debug)]
316pub struct NewBasis {
317    /// Item number of the basis.
318    pub item_number: String,
319    /// Revision code; empty = the latest revision.
320    pub revision_code: String,
321    /// Name of a file of that revision: its current version is pinned.
322    pub file_name: String,
323    /// Or the exact file version (API).
324    pub file_version_id: Option<Uuid>,
325    pub role: BasisRole,
326    pub note: String,
327}
328
329/// Adds a basis link to a simulation revision checked out by the actor.
330///
331/// # Invariants
332///
333/// * **Permission:** CAD editor in the project group of the simulation, which
334///   is in work and checked out by the actor (otherwise 403 / 409); the basis
335///   item and revision must be visible to the actor (looked up before the
336///   transaction, hidden = 404). A simulation cannot be based on itself (400).
337/// * **Locks:** the simulation revision (`FOR UPDATE`) and the basis item
338///   (`FOR SHARE`), so a concurrent `items::delete` cannot remove it.
339/// * **Audit:** `simulation.basis.add` (related to the basis item). A second
340///   identical link (same basis revision and role) gets a 409.
341/// * **Notifications / webhooks:** none.
342/// * **Atomic:** one transaction.
343pub async fn add_basis(
344    pool: &PgPool,
345    actor: &Actor,
346    revision_id: Uuid,
347    input: NewBasis,
348) -> AppResult<SimulationBasis> {
349    let note = domain::limit_text("Note", &input.note, 500)?;
350    let item = items::get_by_number(pool, actor, &input.item_number).await?;
351    let code = input.revision_code.trim();
352    let basis = if code.is_empty() || code.eq_ignore_ascii_case("latest") {
353        items::latest_revision(pool, actor, item.id).await?
354    } else {
355        revisions::get_by_code(pool, actor, item.id, code).await?
356    };
357    let mut tx = pool.begin().await?;
358    let rev = revisions::lock(&mut tx, revision_id).await?;
359    access::ensure_visible(actor, rev.project_id, access::not_found_revision())?;
360    ensure_simulation(&rev)?;
361    revisions::ensure_editable(&rev, actor)?;
362    let id = insert_basis_tx(&mut tx, actor, &rev, &basis, &input, &note).await?;
363    tx.commit().await?;
364    get_basis(pool, actor, id).await
365}
366
367/// The part of [`add_basis`] inside the caller's transaction: `rev` is the
368/// locked, editable simulation revision, `basis` a revision the actor may
369/// read. Also used by [`create_with_basis`].
370async fn insert_basis_tx(
371    tx: &mut PgConnection,
372    actor: &Actor,
373    rev: &Revision,
374    basis: &Revision,
375    input: &NewBasis,
376    note: &str,
377) -> AppResult<Uuid> {
378    if basis.item_id == rev.item_id {
379        return Err(AppError::BadRequest(
380            "A simulation cannot be based on itself.".into(),
381        ));
382    }
383    // Hold the basis item against a concurrent delete (`items::delete` locks
384    // it FOR UPDATE and then checks `item_is_basis`).
385    let basis_exists =
386        sqlx::query_scalar::<_, Uuid>("SELECT id FROM items WHERE id = $1 FOR SHARE")
387            .bind(basis.item_id)
388            .fetch_optional(&mut *tx)
389            .await?
390            .is_some();
391    if !basis_exists {
392        return Err(AppError::not_found("Item not found."));
393    }
394    // The simulated file: an exact version or the current version of a file.
395    #[derive(sqlx::FromRow)]
396    struct Pin {
397        id: Uuid,
398        filename: String,
399        version: i32,
400    }
401    let pin = if let Some(version_id) = input.file_version_id {
402        Some(
403            sqlx::query_as::<_, Pin>(
404                "SELECT fv.id, f.filename, fv.version FROM file_versions fv
405                 JOIN files f ON f.id = fv.file_id
406                 WHERE fv.id = $1 AND f.revision_id = $2",
407            )
408            .bind(version_id)
409            .bind(basis.id)
410            .fetch_optional(&mut *tx)
411            .await?
412            .ok_or_else(|| {
413                AppError::BadRequest(format!(
414                    "The file version does not belong to {}.",
415                    revisions::label(basis)
416                ))
417            })?,
418        )
419    } else if !input.file_name.trim().is_empty() {
420        let name = input.file_name.trim();
421        Some(
422            sqlx::query_as::<_, Pin>(
423                "SELECT fv.id, f.filename, fv.version FROM files f
424                 JOIN file_versions fv ON fv.file_id = f.id AND fv.version = f.current_version
425                 WHERE f.revision_id = $1 AND lower(f.filename) = lower($2)",
426            )
427            .bind(basis.id)
428            .bind(name)
429            .fetch_optional(&mut *tx)
430            .await?
431            .ok_or_else(|| {
432                AppError::BadRequest(format!(
433                    "{} has no file named '{name}'.",
434                    revisions::label(basis)
435                ))
436            })?,
437        )
438    } else {
439        None
440    };
441    let id = Uuid::new_v4();
442    sqlx::query(
443        "INSERT INTO simulation_bases (id, revision_id, basis_revision_id, file_version_id, file_name,
444                                       file_version, role, note, created_by)
445         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
446    )
447    .bind(id)
448    .bind(rev.id)
449    .bind(basis.id)
450    .bind(pin.as_ref().map(|p| p.id))
451    .bind(pin.as_ref().map_or("", |p| p.filename.as_str()))
452    .bind(pin.as_ref().map(|p| p.version))
453    .bind(input.role.as_str())
454    .bind(note)
455    .bind(actor.id)
456    .execute(&mut *tx)
457    .await
458    .map_err(|e| {
459        AppError::unique_violation(
460            e,
461            format!(
462                "{} is already based on {} ({}).",
463                revisions::label(rev),
464                revisions::label(basis),
465                input.role.label().to_lowercase()
466            ),
467        )
468    })?;
469    let file = pin
470        .as_ref()
471        .map(|p| format!(", {} v{}", p.filename, p.version))
472        .unwrap_or_default();
473    audit::record_related(
474        &mut *tx,
475        Some(actor),
476        "simulation.basis.add",
477        "revision",
478        Some(rev.id),
479        Some(basis.item_id),
480        format!(
481            "{} based on {}{file} ({})",
482            revisions::label(rev),
483            revisions::label(basis),
484            input.role.label().to_lowercase()
485        ),
486        json!({ "item_id": rev.item_id, "basis_id": id, "basis_revision_id": basis.id,
487                "file_version_id": pin.as_ref().map(|p| p.id), "role": input.role.as_str() }),
488    )
489    .await?;
490    Ok(id)
491}
492
493/// The name a new simulation gets from its basis: `Simulation – <name>`
494/// (cut to the 200 characters of an item name).
495pub fn default_name(basis_name: &str) -> String {
496    let name = format!("Simulation – {}", basis_name.trim());
497    name.chars().take(200).collect()
498}
499
500/// The description a new simulation gets from its basis.
501pub fn default_description(label: &str, basis_name: &str, material: &str) -> String {
502    let mut text = format!("Analysis of {label} ({}).", basis_name.trim());
503    let material = material.trim();
504    if !material.is_empty() {
505        text.push_str(&format!(" Material of the basis: {material}."));
506    }
507    text
508}
509
510/// Picks the file of the basis revision whose current version a new
511/// simulation pins: the STEP export if there is one (the neutral geometry),
512/// else the first file of a known CAD/mesh format. `files` are
513/// `(filename, format id)`; none fits → `None` (the revision alone is linked).
514pub fn pick_basis_file(files: &[(String, String)]) -> Option<&str> {
515    let mut sorted: Vec<&(String, String)> = files.iter().collect();
516    sorted.sort_by_key(|f| f.0.to_lowercase());
517    sorted
518        .iter()
519        .find(|f| f.1 == "step")
520        .or_else(|| sorted.iter().find(|f| !f.1.is_empty()))
521        .map(|f| f.0.as_str())
522}
523
524/// What the new-simulation form takes over from a basis revision.
525#[derive(Clone, Debug, Serialize)]
526pub struct BasisPrefill {
527    pub revision_id: Uuid,
528    pub item_number: String,
529    pub code: String,
530    /// `PRT-000002/A`.
531    pub label: String,
532    pub name: String,
533    pub project_id: Option<Uuid>,
534    pub project_code: Option<String>,
535    pub default_name: String,
536    pub default_description: String,
537}
538
539/// The values the form *New simulation based on …* is prefilled with.
540/// A basis the actor may not read answers like a missing one (404), so the
541/// caller can fall back to the plain form without echoing anything.
542pub async fn prefill_from_basis(
543    pool: &PgPool,
544    actor: &Actor,
545    revision_id: Uuid,
546) -> AppResult<BasisPrefill> {
547    let basis = revisions::get(pool, actor, revision_id).await?;
548    let label = revisions::label(&basis);
549    Ok(BasisPrefill {
550        revision_id: basis.id,
551        item_number: basis.item_number.clone(),
552        code: basis.code.clone(),
553        default_name: default_name(&basis.name),
554        default_description: default_description(&label, &basis.name, &basis.material),
555        label,
556        name: basis.name,
557        project_id: basis.project_id,
558        project_code: basis.project_code,
559    })
560}
561
562/// A basis revision the actor may read, by item number and revision code
563/// (empty or `latest` = the latest revision). Hidden = 404.
564pub async fn resolve_basis(
565    pool: &PgPool,
566    actor: &Actor,
567    item_number: &str,
568    revision_code: &str,
569) -> AppResult<Revision> {
570    let item = items::get_by_number(pool, actor, item_number).await?;
571    let code = revision_code.trim();
572    if code.is_empty() || code.eq_ignore_ascii_case("latest") {
573        items::latest_revision(pool, actor, item.id).await
574    } else {
575        revisions::get_by_code(pool, actor, item.id, code).await
576    }
577}
578
579/// Creates a simulation item together with its first files and its basis
580/// link – the one-step counterpart of [`items::create_with_files`] followed
581/// by [`add_basis`].
582///
583/// # Invariants
584///
585/// * **Permission:** as [`items::create`] (editor, member of the project
586///   group); `basis` must be a revision the caller already resolved with the
587///   actor's rights ([`resolve_basis`], `revisions::get`), so a hidden basis
588///   never gets here. The item type must be `simulation` (400).
589/// * **Defaults:** an empty name / description becomes
590///   [`default_name`] / [`default_description`] of the basis.
591/// * **Data:** the new revision is checked out by the creator; the current
592///   version of the basis revision's STEP (else first CAD) file is pinned
593///   ([`pick_basis_file`]); without a file the revision alone is linked.
594/// * **Locks:** as `items::create_tx`, the new revision (`FOR UPDATE`) and
595///   the basis item (`FOR SHARE`).
596/// * **Audit:** `item.create` and `simulation.basis.add`, same transaction.
597/// * **Atomic:** one transaction; a failing basis check (e.g. the basis item
598///   deleted meanwhile) or upload creates nothing, not even the item number.
599pub async fn create_with_basis(
600    pool: &PgPool,
601    actor: &Actor,
602    mut input: NewItemFor<'_>,
603) -> AppResult<Revision> {
604    if input.item.item_type != ItemType::Simulation {
605        return Err(AppError::BadRequest(
606            "A basis can only be given for an item of type simulation.".into(),
607        ));
608    }
609    let basis = input.basis;
610    let label = revisions::label(basis);
611    if input.item.name.trim().is_empty() {
612        input.item.name = default_name(&basis.name);
613    }
614    if input.item.description.trim().is_empty() {
615        input.item.description = default_description(&label, &basis.name, &basis.material);
616    }
617    let mut tx = pool.begin().await?;
618    let created = items::create_tx(&mut tx, actor, input.item).await?;
619    let rev = revisions::lock(&mut tx, created.revision_id).await?;
620    if !input.uploads.is_empty() {
621        crate::services::files::store_uploads(&mut tx, actor, &rev, input.uploads, "").await?;
622    }
623    let files = sqlx::query_as::<_, (String, String)>(
624        "SELECT filename, format FROM files WHERE revision_id = $1",
625    )
626    .bind(basis.id)
627    .fetch_all(&mut *tx)
628    .await?;
629    let new_basis = NewBasis {
630        item_number: basis.item_number.clone(),
631        revision_code: basis.code.clone(),
632        file_name: pick_basis_file(&files).unwrap_or_default().to_string(),
633        file_version_id: None,
634        role: input.role,
635        note: String::new(),
636    };
637    insert_basis_tx(&mut tx, actor, &rev, basis, &new_basis, "").await?;
638    tx.commit().await?;
639    revisions::get(pool, actor, created.revision_id).await
640}
641
642/// The input of [`create_with_basis`].
643pub struct NewItemFor<'a> {
644    pub item: items::NewItem,
645    pub uploads: &'a [crate::services::files::Upload],
646    pub basis: &'a Revision,
647    pub role: BasisRole,
648}
649
650/// Removes a basis link from a simulation revision checked out by the actor.
651///
652/// # Invariants
653///
654/// * **Permission:** CAD editor in the project group, simulation revision in
655///   work and checked out by the actor (403 / 409); a link the actor cannot
656///   see is a 404.
657/// * **Locks:** the simulation revision (`FOR UPDATE`).
658/// * **Audit:** `simulation.basis.remove` (related to the basis item).
659/// * **Notifications / webhooks:** none.
660/// * **Atomic:** one transaction.
661pub async fn remove_basis(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<SimulationBasis> {
662    let existing = get_basis_unmasked(pool, actor, id).await?;
663    let mut tx = pool.begin().await?;
664    let rev = revisions::lock(&mut tx, existing.revision_id).await?;
665    revisions::ensure_editable(&rev, actor)?;
666    let removed = sqlx::query("DELETE FROM simulation_bases WHERE id = $1")
667        .bind(id)
668        .execute(&mut *tx)
669        .await?
670        .rows_affected();
671    if removed == 0 {
672        return Err(AppError::not_found("Basis link not found."));
673    }
674    audit::record_related(
675        &mut tx,
676        Some(actor),
677        "simulation.basis.remove",
678        "revision",
679        Some(rev.id),
680        Some(existing.basis_item_id),
681        format!(
682            "{} no longer based on {}/{}",
683            revisions::label(&rev),
684            existing.basis_number,
685            existing.basis_code
686        ),
687        json!({ "item_id": rev.item_id, "basis_id": id }),
688    )
689    .await?;
690    tx.commit().await?;
691    Ok(existing.redact(actor))
692}
693
694/// Copies basis links and result values of `from` to the new revision `to`
695/// (Revise). Returns (basis links, result values).
696pub(crate) async fn copy_to_revision(
697    conn: &mut PgConnection,
698    from: Uuid,
699    to: Uuid,
700) -> AppResult<(u64, u64)> {
701    let bases = sqlx::query(
702        "INSERT INTO simulation_bases (id, revision_id, basis_revision_id, file_version_id, file_name,
703                                       file_version, role, note, created_by, created_at)
704         SELECT gen_random_uuid(), $2, basis_revision_id, file_version_id, file_name, file_version,
705                role, note, created_by, created_at
706         FROM simulation_bases WHERE revision_id = $1",
707    )
708    .bind(from)
709    .bind(to)
710    .execute(&mut *conn)
711    .await?
712    .rows_affected();
713    let results = sqlx::query(
714        "INSERT INTO simulation_results (id, revision_id, position, name, value, unit, lower_limit,
715                                         upper_limit, load_case, solver, solver_version, note,
716                                         created_by, created_at, updated_at)
717         SELECT gen_random_uuid(), $2, position, name, value, unit, lower_limit, upper_limit,
718                load_case, solver, solver_version, note, created_by, created_at, updated_at
719         FROM simulation_results WHERE revision_id = $1",
720    )
721    .bind(from)
722    .bind(to)
723    .execute(&mut *conn)
724    .await?
725    .rows_affected();
726    Ok((bases, results))
727}
728
729/// Whether a revision is the basis of a simulation (an item with such a
730/// revision cannot be deleted).
731pub(crate) async fn item_is_basis(conn: &mut PgConnection, item_id: Uuid) -> AppResult<bool> {
732    Ok(sqlx::query_scalar::<_, bool>(
733        "SELECT EXISTS(SELECT 1 FROM simulation_bases sb JOIN revisions r ON r.id = sb.basis_revision_id
734                       WHERE r.item_id = $1)",
735    )
736    .bind(item_id)
737    .fetch_one(&mut *conn)
738    .await?)
739}
740
741// ---------------------------------------------------------------------------
742// Result values
743// ---------------------------------------------------------------------------
744
745/// A key result value of a simulation revision.
746#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
747pub struct SimulationResult {
748    pub id: Uuid,
749    pub revision_id: Uuid,
750    pub position: i32,
751    pub name: String,
752    pub value: f64,
753    pub unit: String,
754    pub lower_limit: Option<f64>,
755    pub upper_limit: Option<f64>,
756    pub load_case: String,
757    pub solver: String,
758    pub solver_version: String,
759    pub note: String,
760    /// Where an automatic value came from (`file:line`), empty for typed ones.
761    pub source: String,
762    pub updated_at: chrono::DateTime<chrono::Utc>,
763    /// `pass`, `fail` or `none` (no limits).
764    #[sqlx(skip)]
765    pub status: Option<ResultStatus>,
766}
767
768impl SimulationResult {
769    fn with_status(mut self) -> Self {
770        self.status = Some(domain::result_status(
771            self.value,
772            self.lower_limit,
773            self.upper_limit,
774        ));
775        self
776    }
777
778    fn key(&self) -> (String, String) {
779        (self.name.to_lowercase(), self.load_case.to_lowercase())
780    }
781}
782
783const RESULT_SELECT: &str = "
784    SELECT id, revision_id, position, name, value, unit, lower_limit, upper_limit, load_case,
785           solver, solver_version, note, source, updated_at
786    FROM simulation_results";
787
788/// The result values of a revision the actor may read, in their order.
789pub async fn results(
790    pool: &PgPool,
791    actor: &Actor,
792    revision_id: Uuid,
793) -> AppResult<Vec<SimulationResult>> {
794    ensure_revision_visible(pool, actor, revision_id).await?;
795    results_unchecked(pool, revision_id).await
796}
797
798pub(crate) async fn results_unchecked(
799    pool: &PgPool,
800    revision_id: Uuid,
801) -> AppResult<Vec<SimulationResult>> {
802    Ok(sqlx::query_as::<_, SimulationResult>(&format!(
803        "{RESULT_SELECT} WHERE revision_id = $1 ORDER BY position, lower(load_case), lower(name)"
804    ))
805    .bind(revision_id)
806    .fetch_all(pool)
807    .await?
808    .into_iter()
809    .map(SimulationResult::with_status)
810    .collect())
811}
812
813pub async fn get_result(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<SimulationResult> {
814    let r = sqlx::query_as::<_, SimulationResult>(&format!("{RESULT_SELECT} WHERE id = $1"))
815        .bind(id)
816        .fetch_optional(pool)
817        .await?
818        .map(SimulationResult::with_status)
819        .ok_or_else(|| AppError::not_found("Result value not found."))?;
820    ensure_revision_visible(pool, actor, r.revision_id)
821        .await
822        .map_err(|_| AppError::not_found("Result value not found."))?;
823    Ok(r)
824}
825
826/// How many values passed and failed.
827#[derive(Clone, Debug, Default, Serialize)]
828pub struct ResultSummary {
829    pub total: usize,
830    pub pass: usize,
831    pub fail: usize,
832    pub without_limits: usize,
833}
834
835pub fn summarize(results: &[SimulationResult]) -> ResultSummary {
836    let mut s = ResultSummary {
837        total: results.len(),
838        ..Default::default()
839    };
840    for r in results {
841        match r.status {
842            Some(ResultStatus::Pass) => s.pass += 1,
843            Some(ResultStatus::Fail) => s.fail += 1,
844            _ => s.without_limits += 1,
845        }
846    }
847    s
848}
849
850/// A result value as entered or imported.
851#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
852pub struct ResultInput {
853    pub name: String,
854    pub value: f64,
855    #[serde(default)]
856    pub unit: String,
857    #[serde(default, alias = "limit_low")]
858    pub lower_limit: Option<f64>,
859    #[serde(default, alias = "limit_high")]
860    pub upper_limit: Option<f64>,
861    #[serde(default)]
862    pub load_case: String,
863    #[serde(default)]
864    pub solver: String,
865    #[serde(default)]
866    pub solver_version: String,
867    #[serde(default)]
868    pub note: String,
869    /// Where the value came from (`file:line`, set by exchange files and
870    /// templates; never read from a request).
871    #[serde(skip)]
872    pub source: String,
873}
874
875impl ResultInput {
876    pub fn validated(self) -> AppResult<ResultInput> {
877        let name = domain::require_text("Result name", &self.name, 200)?;
878        domain::validate_result_numbers(&name, self.value, self.lower_limit, self.upper_limit)?;
879        Ok(ResultInput {
880            unit: domain::limit_text("Unit", &self.unit, 32)?,
881            load_case: domain::limit_text("Load case", &self.load_case, 200)?,
882            solver: domain::limit_text("Solver", &self.solver, 100)?,
883            solver_version: domain::limit_text("Solver version", &self.solver_version, 100)?,
884            note: domain::limit_text("Note", &self.note, 1000)?,
885            name,
886            ..self
887        })
888    }
889}
890
891/// Locks a simulation revision the actor may edit.
892pub(crate) async fn lock_editable(
893    conn: &mut PgConnection,
894    actor: &Actor,
895    revision_id: Uuid,
896) -> AppResult<Revision> {
897    let rev = revisions::lock(conn, revision_id).await?;
898    access::ensure_visible(actor, rev.project_id, access::not_found_revision())?;
899    ensure_simulation(&rev)?;
900    revisions::ensure_editable(&rev, actor)?;
901    Ok(rev)
902}
903
904async fn insert_result(
905    conn: &mut PgConnection,
906    actor: &Actor,
907    rev: &Revision,
908    input: &ResultInput,
909) -> AppResult<Uuid> {
910    let id = Uuid::new_v4();
911    sqlx::query(
912        "INSERT INTO simulation_results (id, revision_id, position, name, value, unit, lower_limit,
913                                         upper_limit, load_case, solver, solver_version, note, created_by, source)
914         VALUES ($1, $2, (SELECT COALESCE(MAX(position), 0) + 1 FROM simulation_results WHERE revision_id = $2),
915                 $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
916    )
917    .bind(id)
918    .bind(rev.id)
919    .bind(&input.name)
920    .bind(input.value)
921    .bind(&input.unit)
922    .bind(input.lower_limit)
923    .bind(input.upper_limit)
924    .bind(&input.load_case)
925    .bind(&input.solver)
926    .bind(&input.solver_version)
927    .bind(&input.note)
928    .bind(actor.id)
929    .bind(&input.source)
930    .execute(&mut *conn)
931    .await
932    .map_err(|e| {
933        AppError::unique_violation(
934            e,
935            format!(
936                "{} already has a result '{}'{}.",
937                revisions::label(rev),
938                input.name,
939                if input.load_case.is_empty() {
940                    String::new()
941                } else {
942                    format!(" for load case '{}'", input.load_case)
943                }
944            ),
945        )
946    })?;
947    Ok(id)
948}
949
950async fn count_results(conn: &mut PgConnection, revision_id: Uuid) -> AppResult<usize> {
951    let n = sqlx::query_scalar::<_, i64>(
952        "SELECT COUNT(*) FROM simulation_results WHERE revision_id = $1",
953    )
954    .bind(revision_id)
955    .fetch_one(&mut *conn)
956    .await?;
957    Ok(usize::try_from(n).unwrap_or(usize::MAX))
958}
959
960/// Adds a result value to a simulation revision checked out by the actor.
961///
962/// # Invariants
963///
964/// * **Permission:** CAD editor in the project group, revision of a
965///   simulation item (400 otherwise), in work and checked out by the actor
966///   (403 / 409); a hidden revision is a 404.
967/// * **Locks:** the simulation revision (`FOR UPDATE`).
968/// * **Data:** at most 2000 result values per revision (400); the same name
969///   and load case twice gets a 409.
970/// * **Audit:** `simulation.result.add`.
971/// * **Notifications:** none.
972/// * **Webhooks:** `simulation.result_added`, emitted in the same transaction.
973/// * **Atomic:** one transaction.
974pub async fn add_result(
975    pool: &PgPool,
976    actor: &Actor,
977    revision_id: Uuid,
978    input: ResultInput,
979) -> AppResult<SimulationResult> {
980    let input = input.validated()?;
981    let mut tx = pool.begin().await?;
982    let rev = lock_editable(&mut tx, actor, revision_id).await?;
983    if count_results(&mut tx, rev.id).await? >= MAX_RESULTS {
984        return Err(AppError::BadRequest(format!(
985            "A simulation revision can hold at most {MAX_RESULTS} result values."
986        )));
987    }
988    let id = insert_result(&mut tx, actor, &rev, &input).await?;
989    audit::record(
990        &mut tx,
991        Some(actor),
992        "simulation.result.add",
993        "revision",
994        Some(rev.id),
995        format!(
996            "{}: result {} = {} {}",
997            revisions::label(&rev),
998            input.name,
999            input.value,
1000            input.unit
1001        ),
1002        json!({ "item_id": rev.item_id, "result_id": id }),
1003    )
1004    .await?;
1005    let status = domain::result_status(input.value, input.lower_limit, input.upper_limit);
1006    webhooks::emit(
1007        &mut tx,
1008        Some(actor),
1009        WebhookEvent::SimulationResultAdded,
1010        &[rev.project_id],
1011        json!({
1012            "revision": webhooks::revision_object(&rev, &rev.state),
1013            "result": {
1014                "id": id, "name": input.name, "value": input.value, "unit": input.unit,
1015                "lower_limit": input.lower_limit, "upper_limit": input.upper_limit,
1016                "load_case": input.load_case, "solver": input.solver,
1017                "solver_version": input.solver_version, "status": status,
1018            },
1019        }),
1020    )
1021    .await?;
1022    tx.commit().await?;
1023    get_result(pool, actor, id).await
1024}
1025
1026/// Changes a result value of a simulation revision checked out by the actor.
1027///
1028/// # Invariants
1029///
1030/// * **Permission:** as [`add_result`]; a value the actor cannot see is a 404.
1031/// * **Locks:** the simulation revision (`FOR UPDATE`).
1032/// * **Data:** a hand edit clears the value's `source` (the origin of an
1033///   import or exchange file); the same name and load case as another value
1034///   gets a 409.
1035/// * **Audit:** `simulation.result.update` (with the previous value and unit).
1036/// * **Notifications / webhooks:** none.
1037/// * **Atomic:** one transaction.
1038pub async fn update_result(
1039    pool: &PgPool,
1040    actor: &Actor,
1041    id: Uuid,
1042    input: ResultInput,
1043) -> AppResult<SimulationResult> {
1044    let input = input.validated()?;
1045    let existing = get_result(pool, actor, id).await?;
1046    let mut tx = pool.begin().await?;
1047    let rev = lock_editable(&mut tx, actor, existing.revision_id).await?;
1048    let updated = sqlx::query(
1049        "UPDATE simulation_results SET name = $2, value = $3, unit = $4, lower_limit = $5,
1050                upper_limit = $6, load_case = $7, solver = $8, solver_version = $9, note = $10,
1051                source = '', updated_at = now()
1052         WHERE id = $1",
1053    )
1054    .bind(id)
1055    .bind(&input.name)
1056    .bind(input.value)
1057    .bind(&input.unit)
1058    .bind(input.lower_limit)
1059    .bind(input.upper_limit)
1060    .bind(&input.load_case)
1061    .bind(&input.solver)
1062    .bind(&input.solver_version)
1063    .bind(&input.note)
1064    .execute(&mut *tx)
1065    .await
1066    .map_err(|e| {
1067        AppError::unique_violation(
1068            e,
1069            format!(
1070                "{} already has a result '{}' for this load case.",
1071                revisions::label(&rev),
1072                input.name
1073            ),
1074        )
1075    })?
1076    .rows_affected();
1077    if updated == 0 {
1078        return Err(AppError::not_found("Result value not found."));
1079    }
1080    audit::record(
1081        &mut tx,
1082        Some(actor),
1083        "simulation.result.update",
1084        "revision",
1085        Some(rev.id),
1086        format!(
1087            "{}: result {} = {} {} (was {} {})",
1088            revisions::label(&rev),
1089            input.name,
1090            input.value,
1091            input.unit,
1092            existing.value,
1093            existing.unit
1094        ),
1095        json!({ "item_id": rev.item_id, "result_id": id }),
1096    )
1097    .await?;
1098    tx.commit().await?;
1099    get_result(pool, actor, id).await
1100}
1101
1102/// Removes a result value from a simulation revision checked out by the actor.
1103///
1104/// # Invariants
1105///
1106/// * **Permission:** as [`add_result`]; a value the actor cannot see is a 404.
1107/// * **Locks:** the simulation revision (`FOR UPDATE`).
1108/// * **Audit:** `simulation.result.remove`.
1109/// * **Notifications / webhooks:** none.
1110/// * **Atomic:** one transaction.
1111pub async fn remove_result(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<SimulationResult> {
1112    let existing = get_result(pool, actor, id).await?;
1113    let mut tx = pool.begin().await?;
1114    let rev = lock_editable(&mut tx, actor, existing.revision_id).await?;
1115    let removed = sqlx::query("DELETE FROM simulation_results WHERE id = $1")
1116        .bind(id)
1117        .execute(&mut *tx)
1118        .await?
1119        .rows_affected();
1120    if removed == 0 {
1121        return Err(AppError::not_found("Result value not found."));
1122    }
1123    audit::record(
1124        &mut tx,
1125        Some(actor),
1126        "simulation.result.remove",
1127        "revision",
1128        Some(rev.id),
1129        format!(
1130            "{}: removed the result {}",
1131            revisions::label(&rev),
1132            existing.name
1133        ),
1134        json!({ "item_id": rev.item_id, "result_id": id }),
1135    )
1136    .await?;
1137    tx.commit().await?;
1138    Ok(existing)
1139}
1140
1141/// What an import of result values did.
1142#[derive(Clone, Debug, Default, Serialize)]
1143pub struct ImportSummary {
1144    pub added: usize,
1145    pub updated: usize,
1146    pub removed: usize,
1147}
1148
1149/// Writes a set of result values in one transaction: values with the same
1150/// name and load case are updated, new ones appended. With `replace`, the
1151/// values that are not in the set are removed (the set becomes the result
1152/// table – what a solver script wants).
1153///
1154/// # Invariants
1155///
1156/// * **Permission:** as [`add_result`].
1157/// * **Locks:** the simulation revision (`FOR UPDATE`); the existing values are
1158///   read under that lock, so a value added concurrently is updated, not
1159///   inserted twice.
1160/// * **Data:** rows are validated first; a name and load case listed twice, or
1161///   more than 2000 values (in the set or in the result table afterwards), is
1162///   a 400 and nothing is written.
1163/// * **Audit:** one `simulation.result.import` for the whole set.
1164/// * **Notifications:** none.
1165/// * **Webhooks:** one `simulation.result_added`, only when a value was added
1166///   or updated.
1167/// * **Atomic:** one transaction.
1168pub async fn import_results(
1169    pool: &PgPool,
1170    actor: &Actor,
1171    revision_id: Uuid,
1172    rows: Vec<ResultInput>,
1173    replace: bool,
1174) -> AppResult<ImportSummary> {
1175    if rows.len() > MAX_RESULTS {
1176        return Err(AppError::BadRequest(format!(
1177            "At most {MAX_RESULTS} result values can be imported at once."
1178        )));
1179    }
1180    let mut inputs: Vec<ResultInput> = Vec::new();
1181    let mut seen = std::collections::HashSet::new();
1182    for row in rows {
1183        let row = row.validated()?;
1184        if !seen.insert((row.name.to_lowercase(), row.load_case.to_lowercase())) {
1185            return Err(AppError::BadRequest(format!(
1186                "The result '{}'{} is listed twice.",
1187                row.name,
1188                if row.load_case.is_empty() {
1189                    String::new()
1190                } else {
1191                    format!(" (load case '{}')", row.load_case)
1192                }
1193            )));
1194        }
1195        inputs.push(row);
1196    }
1197    let mut tx = pool.begin().await?;
1198    let rev = lock_editable(&mut tx, actor, revision_id).await?;
1199    let summary = write_results(&mut tx, actor, &rev, &inputs, replace, &Origin::Import).await?;
1200    tx.commit().await?;
1201    Ok(summary)
1202}
1203
1204/// Where a set of result values comes from (audit trail).
1205pub(crate) enum Origin {
1206    /// A table or the REST API.
1207    Import,
1208    /// The exchange file `teamcraft-results.json|csv` of a check-in.
1209    Exchange(String),
1210    /// Extraction templates of the project group.
1211    Templates(Vec<String>),
1212    /// A proposal from a report that a CAD editor accepted (file name).
1213    Proposal(String),
1214}
1215
1216impl Origin {
1217    fn action(&self) -> &'static str {
1218        match self {
1219            Origin::Import => "simulation.result.import",
1220            Origin::Exchange(_) => "simulation.result.exchange",
1221            Origin::Templates(_) => "simulation.result.template",
1222            Origin::Proposal(_) => "simulation.proposal.accept",
1223        }
1224    }
1225
1226    fn what(&self) -> String {
1227        match self {
1228            Origin::Import => "imported result values".into(),
1229            Origin::Exchange(file) => format!("result values from {file}"),
1230            Origin::Templates(files) => {
1231                format!("result values extracted from {}", files.join(", "))
1232            }
1233            Origin::Proposal(file) => format!("accepted a proposed result value from {file}"),
1234        }
1235    }
1236
1237    fn details(&self) -> serde_json::Value {
1238        match self {
1239            Origin::Import => json!("import"),
1240            Origin::Exchange(file) => json!({ "exchange_file": file }),
1241            Origin::Templates(files) => json!({ "template_files": files }),
1242            Origin::Proposal(file) => json!({ "proposal_file": file }),
1243        }
1244    }
1245}
1246
1247/// Writes validated, unique result values into a locked, editable simulation
1248/// revision inside the caller's transaction: same name and load case =
1249/// update, new = append; with `replace` the others are removed. One audit
1250/// entry (and webhook) for the whole set.
1251pub(crate) async fn write_results(
1252    tx: &mut PgConnection,
1253    actor: &Actor,
1254    rev: &Revision,
1255    inputs: &[ResultInput],
1256    replace: bool,
1257    origin: &Origin,
1258) -> AppResult<ImportSummary> {
1259    // Read the current values under the revision lock: a value added
1260    // concurrently must be updated, not inserted a second time.
1261    #[derive(sqlx::FromRow)]
1262    struct Existing {
1263        id: Uuid,
1264        name: String,
1265        load_case: String,
1266    }
1267    let existing: HashMap<(String, String), Uuid> = sqlx::query_as::<_, Existing>(
1268        "SELECT id, name, load_case FROM simulation_results WHERE revision_id = $1",
1269    )
1270    .bind(rev.id)
1271    .fetch_all(&mut *tx)
1272    .await?
1273    .into_iter()
1274    .map(|r| ((r.name.to_lowercase(), r.load_case.to_lowercase()), r.id))
1275    .collect();
1276    let mut summary = ImportSummary::default();
1277    if replace {
1278        let keep: Vec<Uuid> = inputs
1279            .iter()
1280            .filter_map(|i| {
1281                existing
1282                    .get(&(i.name.to_lowercase(), i.load_case.to_lowercase()))
1283                    .copied()
1284            })
1285            .collect();
1286        summary.removed = sqlx::query(
1287            "DELETE FROM simulation_results WHERE revision_id = $1 AND NOT (id = ANY($2))",
1288        )
1289        .bind(rev.id)
1290        .bind(&keep)
1291        .execute(&mut *tx)
1292        .await?
1293        .rows_affected() as usize;
1294    }
1295    for input in inputs {
1296        let key = (input.name.to_lowercase(), input.load_case.to_lowercase());
1297        match existing.get(&key) {
1298            Some(id) => {
1299                sqlx::query(
1300                    "UPDATE simulation_results SET name = $2, value = $3, unit = $4, lower_limit = $5,
1301                            upper_limit = $6, load_case = $7, solver = $8, solver_version = $9,
1302                            note = $10, source = $11, updated_at = now()
1303                     WHERE id = $1",
1304                )
1305                .bind(id)
1306                .bind(&input.name)
1307                .bind(input.value)
1308                .bind(&input.unit)
1309                .bind(input.lower_limit)
1310                .bind(input.upper_limit)
1311                .bind(&input.load_case)
1312                .bind(&input.solver)
1313                .bind(&input.solver_version)
1314                .bind(&input.note)
1315                .bind(&input.source)
1316                .execute(&mut *tx)
1317                .await?;
1318                summary.updated += 1;
1319            }
1320            None => {
1321                insert_result(tx, actor, rev, input).await?;
1322                summary.added += 1;
1323            }
1324        }
1325    }
1326    if count_results(tx, rev.id).await? > MAX_RESULTS {
1327        return Err(AppError::BadRequest(format!(
1328            "A simulation revision can hold at most {MAX_RESULTS} result values."
1329        )));
1330    }
1331    audit::record(
1332        tx,
1333        Some(actor),
1334        origin.action(),
1335        "revision",
1336        Some(rev.id),
1337        format!(
1338            "{}: {} ({} added, {} updated, {} removed)",
1339            revisions::label(rev),
1340            origin.what(),
1341            summary.added,
1342            summary.updated,
1343            summary.removed
1344        ),
1345        json!({ "item_id": rev.item_id, "added": summary.added, "updated": summary.updated,
1346                "removed": summary.removed, "replace": replace, "origin": origin.details() }),
1347    )
1348    .await?;
1349    if summary.added + summary.updated > 0 {
1350        webhooks::emit(
1351            tx,
1352            Some(actor),
1353            WebhookEvent::SimulationResultAdded,
1354            &[rev.project_id],
1355            json!({
1356                "revision": webhooks::revision_object(rev, &rev.state),
1357                "import": { "added": summary.added, "updated": summary.updated,
1358                            "removed": summary.removed, "replace": replace },
1359            }),
1360        )
1361        .await?;
1362    }
1363    Ok(summary)
1364}
1365
1366/// Result values from the files stored with a simulation revision: the
1367/// exchange file `teamcraft-results.json|csv` (at most 1 MB, schema in
1368/// [`crate::resultscan::parse_exchange`]) and the extraction templates of the
1369/// project group applied to the small text files. Runs inside the
1370/// transaction of the upload or check-in: a malformed exchange file refuses
1371/// it as a whole, nothing is half-written. Other item types: nothing.
1372pub(crate) async fn apply_file_values(
1373    conn: &mut PgConnection,
1374    actor: &Actor,
1375    rev: &Revision,
1376    uploads: &[crate::services::files::Upload],
1377) -> AppResult<()> {
1378    use crate::{resultscan, services::result_templates};
1379    if rev.item_type != ItemType::Simulation.as_str() {
1380        return Ok(());
1381    }
1382    let named =
1383        |u: &crate::services::files::Upload| crate::services::files::stored_name(&u.filename);
1384    let exchange: Vec<_> = uploads
1385        .iter()
1386        .filter(|u| resultscan::is_exchange_name(&named(u)))
1387        .collect();
1388    if exchange.len() > 1 {
1389        return Err(AppError::BadRequest(format!(
1390            "Check in either {} or {}, not both.",
1391            resultscan::EXCHANGE_JSON,
1392            resultscan::EXCHANGE_CSV
1393        )));
1394    }
1395    // templates of the group: small text files, never the exchange file
1396    let mut found: Vec<ResultInput> = Vec::new();
1397    let mut files: Vec<String> = Vec::new();
1398    if let Some(project_id) = rev.project_id {
1399        let templates = result_templates::enabled_for(conn, project_id).await?;
1400        if !templates.is_empty() {
1401            for upload in uploads.iter().filter(|u| !u.scan_head.is_empty()) {
1402                let name = named(upload);
1403                if resultscan::is_exchange_name(&name) {
1404                    continue;
1405                }
1406                let values = resultscan::extract_with_tail(
1407                    &name,
1408                    &upload.scan_head,
1409                    &upload.scan_tail,
1410                    &templates,
1411                );
1412                if !values.is_empty() {
1413                    files.push(name);
1414                }
1415                found.extend(values);
1416            }
1417        }
1418    }
1419    if !found.is_empty() {
1420        let rows = unique_rows(found)?;
1421        write_results(conn, actor, rev, &rows, false, &Origin::Templates(files)).await?;
1422    }
1423    if let Some(upload) = exchange.first() {
1424        let name = named(upload);
1425        if upload.blob.size > resultscan::EXCHANGE_MAX_BYTES {
1426            return Err(AppError::BadRequest(format!(
1427                "{name} is larger than {} MB – the exchange file holds key numbers, not result fields.",
1428                resultscan::EXCHANGE_MAX_BYTES / (1024 * 1024)
1429            )));
1430        }
1431        let rows =
1432            resultscan::parse_exchange(&name, &upload.scan_head).map_err(AppError::BadRequest)?;
1433        let rows: Vec<ResultInput> = rows
1434            .into_iter()
1435            .enumerate()
1436            .map(|(i, mut r)| {
1437                r.source = format!("{name}:{}", i + 1);
1438                r
1439            })
1440            .collect();
1441        let rows = unique_rows_strict(rows, &name)?;
1442        write_results(conn, actor, rev, &rows, false, &Origin::Exchange(name)).await?;
1443    }
1444    Ok(())
1445}
1446
1447/// One value per name and load case; a later one replaces an earlier one
1448/// (two templates, or the same template in two files).
1449fn unique_rows(rows: Vec<ResultInput>) -> AppResult<Vec<ResultInput>> {
1450    let mut out: Vec<ResultInput> = Vec::new();
1451    for row in rows {
1452        let row = row.validated()?;
1453        let key = (row.name.to_lowercase(), row.load_case.to_lowercase());
1454        match out
1455            .iter_mut()
1456            .find(|r| (r.name.to_lowercase(), r.load_case.to_lowercase()) == key)
1457        {
1458            Some(existing) => *existing = row,
1459            None => out.push(row),
1460        }
1461    }
1462    Ok(out)
1463}
1464
1465/// Like an import: a result listed twice in the exchange file is an error.
1466fn unique_rows_strict(rows: Vec<ResultInput>, filename: &str) -> AppResult<Vec<ResultInput>> {
1467    let mut seen = std::collections::HashSet::new();
1468    for row in &rows {
1469        if !seen.insert((row.name.to_lowercase(), row.load_case.to_lowercase())) {
1470            return Err(AppError::BadRequest(format!(
1471                "{filename}: the result '{}'{} is listed twice.",
1472                row.name,
1473                if row.load_case.is_empty() {
1474                    String::new()
1475                } else {
1476                    format!(" (load case '{}')", row.load_case)
1477                }
1478            )));
1479        }
1480    }
1481    Ok(rows)
1482}
1483
1484// ---------------------------------------------------------------------------
1485// Result tables (CSV / Excel)
1486// ---------------------------------------------------------------------------
1487
1488/// The column a header names (case, spaces and punctuation ignored).
1489fn result_field(header: &str) -> Option<&'static str> {
1490    let h: String = header
1491        .chars()
1492        .filter(|c| c.is_alphanumeric())
1493        .flat_map(char::to_lowercase)
1494        .collect();
1495    Some(match h.as_str() {
1496        "name" | "result" | "quantity" | "parameter" | "kennwert" | "grosse" | "größe" => "name",
1497        "value" | "wert" | "resultvalue" => "value",
1498        "unit" | "units" | "einheit" => "unit",
1499        "limitlow" | "limitmin" | "lowerlimit" | "lower" | "min" | "minimum" | "lowlimit"
1500        | "untergrenze" => "lower_limit",
1501        "limithigh" | "limitmax" | "upperlimit" | "upper" | "max" | "maximum" | "highlimit"
1502        | "limit" | "obergrenze" => "upper_limit",
1503        "loadcase" | "case" | "lastfall" | "subcase" | "step" => "load_case",
1504        "solver" | "löser" | "loser" => "solver",
1505        "solverversion" | "version" => "solver_version",
1506        "note" | "notes" | "comment" | "bemerkung" => "note",
1507        _ => return None,
1508    })
1509}
1510
1511/// A number in a result table: `2.5e8`, `-0,35`, `1.234,5`, `125 MPa`.
1512pub(crate) fn table_number(raw: &str) -> Option<f64> {
1513    let s = raw.trim();
1514    if s.is_empty() {
1515        return None;
1516    }
1517    if let Ok(v) = s.parse::<f64>() {
1518        return v.is_finite().then_some(v);
1519    }
1520    if let Ok(v) = s.replace(',', ".").parse::<f64>() {
1521        return v.is_finite().then_some(v);
1522    }
1523    // the number in front of a unit (`125 MPa`), without exponents
1524    let first = s.split_whitespace().next().unwrap_or(s);
1525    first
1526        .parse::<f64>()
1527        .ok()
1528        .or_else(|| first.replace(',', ".").parse::<f64>().ok())
1529        .or_else(|| bomtable::parse_number(s))
1530        .filter(|v| v.is_finite())
1531}
1532
1533/// Reads result values from a CSV/TSV or spreadsheet table with the header
1534/// `name,value,unit,limit_low,limit_high,load_case` (further columns:
1535/// `solver`, `solver_version`, `note`; German names and `min`/`max` work
1536/// too). Rows without a name are skipped; a value that is not a number is
1537/// an error naming the row.
1538pub fn parse_results_table(filename: &str, bytes: &[u8]) -> Result<Vec<ResultInput>, String> {
1539    let grid = bomtable::read_grid(filename, bytes)?;
1540    let header_at = grid
1541        .iter()
1542        .take(30)
1543        .position(|row| {
1544            let fields: Vec<_> = row.iter().filter_map(|c| result_field(c)).collect();
1545            fields.contains(&"name") && fields.contains(&"value")
1546        })
1547        .ok_or_else(|| {
1548            format!(
1549                "{filename}: no header row with the columns name and value \
1550                 (expected: name,value,unit,limit_low,limit_high,load_case)."
1551            )
1552        })?;
1553    let mut columns: HashMap<&'static str, usize> = HashMap::new();
1554    for (i, cell) in grid[header_at].iter().enumerate() {
1555        if let Some(field) = result_field(cell) {
1556            columns.entry(field).or_insert(i);
1557        }
1558    }
1559    let cell = |row: &[String], field: &str| -> String {
1560        columns
1561            .get(field)
1562            .and_then(|i| row.get(*i))
1563            .map(|s| s.trim().to_string())
1564            .unwrap_or_default()
1565    };
1566    let mut out = Vec::new();
1567    for (index, row) in grid.iter().enumerate().skip(header_at + 1) {
1568        let line = index + 1;
1569        let name = cell(row, "name");
1570        if name.is_empty() {
1571            continue;
1572        }
1573        let number = |field: &str, label: &str| -> Result<Option<f64>, String> {
1574            let raw = cell(row, field);
1575            if raw.is_empty() {
1576                return Ok(None);
1577            }
1578            table_number(&raw)
1579                .map(Some)
1580                .ok_or_else(|| format!("Row {line}: {label} '{raw}' of '{name}' is not a number."))
1581        };
1582        let value = number("value", "the value")?
1583            .ok_or_else(|| format!("Row {line}: '{name}' has no value."))?;
1584        out.push(ResultInput {
1585            value,
1586            unit: cell(row, "unit"),
1587            lower_limit: number("lower_limit", "the lower limit")?,
1588            upper_limit: number("upper_limit", "the upper limit")?,
1589            load_case: cell(row, "load_case"),
1590            solver: cell(row, "solver"),
1591            solver_version: cell(row, "solver_version"),
1592            note: cell(row, "note"),
1593            source: String::new(),
1594            name,
1595        });
1596        if out.len() > MAX_RESULTS {
1597            return Err(format!(
1598                "{filename} has more than {MAX_RESULTS} result values."
1599            ));
1600        }
1601    }
1602    if out.is_empty() {
1603        return Err(format!("{filename} contains no result values."));
1604    }
1605    Ok(out)
1606}
1607
1608/// The result values as CSV, in the import layout.
1609pub fn to_csv(results: &[SimulationResult]) -> String {
1610    use crate::util::csv_field;
1611    let num = |v: Option<f64>| v.map(|v| v.to_string()).unwrap_or_default();
1612    let mut out = String::from(
1613        "name,value,unit,limit_low,limit_high,load_case,solver,solver_version,note,status\n",
1614    );
1615    for r in results {
1616        let fields = [
1617            csv_field(&r.name),
1618            r.value.to_string(),
1619            csv_field(&r.unit),
1620            num(r.lower_limit),
1621            num(r.upper_limit),
1622            csv_field(&r.load_case),
1623            csv_field(&r.solver),
1624            csv_field(&r.solver_version),
1625            csv_field(&r.note),
1626            r.status.map_or("none", ResultStatus::as_str).to_string(),
1627        ];
1628        out.push_str(&fields.join(","));
1629        out.push('\n');
1630    }
1631    out
1632}
1633
1634// ---------------------------------------------------------------------------
1635// Comparison between two revisions
1636// ---------------------------------------------------------------------------
1637
1638/// One result value in either revision.
1639#[derive(Clone, Debug, Serialize)]
1640pub struct ResultDiff {
1641    pub change: Change,
1642    pub name: String,
1643    pub load_case: String,
1644    pub unit: String,
1645    pub before: Option<SimulationResult>,
1646    pub after: Option<SimulationResult>,
1647    /// `after − before` and the relative change in percent.
1648    pub delta: Option<f64>,
1649    pub delta_percent: Option<f64>,
1650}
1651
1652/// Matches the values of two revisions by name and load case (pure).
1653pub fn compare_results(before: &[SimulationResult], after: &[SimulationResult]) -> Vec<ResultDiff> {
1654    let mut out = Vec::new();
1655    let old: HashMap<(String, String), &SimulationResult> =
1656        before.iter().map(|r| (r.key(), r)).collect();
1657    let mut matched = std::collections::HashSet::new();
1658    for a in after {
1659        let key = a.key();
1660        match old.get(&key) {
1661            Some(b) => {
1662                matched.insert(key);
1663                let same = b.value == a.value
1664                    && b.unit == a.unit
1665                    && b.lower_limit == a.lower_limit
1666                    && b.upper_limit == a.upper_limit;
1667                let delta = a.value - b.value;
1668                out.push(ResultDiff {
1669                    change: if same {
1670                        Change::Unchanged
1671                    } else {
1672                        Change::Changed
1673                    },
1674                    name: a.name.clone(),
1675                    load_case: a.load_case.clone(),
1676                    unit: a.unit.clone(),
1677                    delta: Some(delta),
1678                    delta_percent: (b.value != 0.0 && b.unit == a.unit)
1679                        .then(|| delta / b.value.abs() * 100.0),
1680                    before: Some((*b).clone()),
1681                    after: Some(a.clone()),
1682                });
1683            }
1684            None => out.push(ResultDiff {
1685                change: Change::Added,
1686                name: a.name.clone(),
1687                load_case: a.load_case.clone(),
1688                unit: a.unit.clone(),
1689                before: None,
1690                after: Some(a.clone()),
1691                delta: None,
1692                delta_percent: None,
1693            }),
1694        }
1695    }
1696    for b in before {
1697        if !matched.contains(&b.key()) {
1698            out.push(ResultDiff {
1699                change: Change::Removed,
1700                name: b.name.clone(),
1701                load_case: b.load_case.clone(),
1702                unit: b.unit.clone(),
1703                before: Some(b.clone()),
1704                after: None,
1705                delta: None,
1706                delta_percent: None,
1707            });
1708        }
1709    }
1710    out
1711}
1712
1713/// Counts of a comparison.
1714#[derive(Clone, Debug, Default, Serialize)]
1715pub struct CompareSummary {
1716    pub added: usize,
1717    pub removed: usize,
1718    pub changed: usize,
1719    pub unchanged: usize,
1720}
1721
1722pub fn compare_summary(diffs: &[ResultDiff]) -> CompareSummary {
1723    let mut s = CompareSummary::default();
1724    for d in diffs {
1725        match d.change {
1726            Change::Added => s.added += 1,
1727            Change::Removed => s.removed += 1,
1728            Change::Changed => s.changed += 1,
1729            Change::Unchanged => s.unchanged += 1,
1730        }
1731    }
1732    s
1733}
1734
1735/// Compares the result values of two revisions of the same simulation.
1736pub async fn compare(
1737    pool: &PgPool,
1738    before: &Revision,
1739    after: &Revision,
1740) -> AppResult<Vec<ResultDiff>> {
1741    if before.item_id != after.item_id {
1742        return Err(AppError::BadRequest(
1743            "Only revisions of the same simulation can be compared.".into(),
1744        ));
1745    }
1746    let (b, a) = tokio::try_join!(
1747        results_unchecked(pool, before.id),
1748        results_unchecked(pool, after.id)
1749    )?;
1750    Ok(compare_results(&b, &a))
1751}
1752
1753/// Largest picture shown on the simulation page (the browser loads each one).
1754pub const GALLERY_MAX_BYTES: i64 = 8 * 1024 * 1024;
1755
1756/// Pictures shown on the simulation page at most.
1757pub const GALLERY_MAX_IMAGES: usize = 12;
1758
1759/// Whether a file of a simulation revision is shown as a picture in the
1760/// gallery: PNG, JPEG or WebP (by file name) of at most [`GALLERY_MAX_BYTES`].
1761/// Rendered result pictures are the cheap way to show a CFD or FEA result –
1762/// nothing is parsed, the browser draws the file.
1763pub fn is_gallery_image(filename: &str, size_bytes: i64) -> bool {
1764    let lower = filename.to_ascii_lowercase();
1765    let ext = lower.rsplit_once('.').map(|(_, e)| e).unwrap_or("");
1766    matches!(ext, "png" | "jpg" | "jpeg" | "webp")
1767        && size_bytes > 0
1768        && size_bytes <= GALLERY_MAX_BYTES
1769}
1770
1771#[cfg(test)]
1772mod tests {
1773    use super::*;
1774
1775    #[test]
1776    fn defaults_from_the_basis() {
1777        assert_eq!(
1778            default_name("  Mounting bracket "),
1779            "Simulation – Mounting bracket"
1780        );
1781        let long = "x".repeat(300);
1782        assert_eq!(default_name(&long).chars().count(), 200);
1783        assert_eq!(
1784            default_description("PRT-000002/A", "Mounting bracket", ""),
1785            "Analysis of PRT-000002/A (Mounting bracket)."
1786        );
1787        assert_eq!(
1788            default_description("PRT-000002/A", "Mounting bracket", " S235 "),
1789            "Analysis of PRT-000002/A (Mounting bracket). Material of the basis: S235."
1790        );
1791    }
1792
1793    #[test]
1794    fn the_pinned_basis_file() {
1795        let f = |n: &str, fmt: &str| (n.to_string(), fmt.to_string());
1796        assert_eq!(pick_basis_file(&[]), None);
1797        // no file of a known format: link the revision only
1798        assert_eq!(pick_basis_file(&[f("notes.txt", "")]), None);
1799        // STEP wins over native CAD files, whatever the order
1800        let files = [f("a.ipt", "inventor"), f("z.step", "step"), f("n.txt", "")];
1801        assert_eq!(pick_basis_file(&files), Some("z.step"));
1802        // else the first CAD file by name
1803        let files = [f("b.ipt", "inventor"), f("a.sldprt", "solidworks")];
1804        assert_eq!(pick_basis_file(&files), Some("a.sldprt"));
1805    }
1806
1807    #[test]
1808    fn gallery_takes_small_pictures_only() {
1809        assert!(is_gallery_image("pressure.PNG", 1000));
1810        assert!(is_gallery_image("v.v2.jpeg", GALLERY_MAX_BYTES));
1811        assert!(is_gallery_image("streamlines.webp", 1));
1812        assert!(!is_gallery_image("huge.png", GALLERY_MAX_BYTES + 1));
1813        assert!(!is_gallery_image("empty.png", 0));
1814        assert!(!is_gallery_image("logo.svg", 10), "SVG can carry scripts");
1815        assert!(!is_gallery_image("result.vtu", 10));
1816        assert!(!is_gallery_image("png", 10));
1817    }
1818
1819    fn result(name: &str, load_case: &str, value: f64, upper: Option<f64>) -> SimulationResult {
1820        SimulationResult {
1821            id: Uuid::new_v4(),
1822            revision_id: Uuid::nil(),
1823            position: 0,
1824            name: name.into(),
1825            value,
1826            unit: "MPa".into(),
1827            lower_limit: None,
1828            upper_limit: upper,
1829            load_case: load_case.into(),
1830            solver: String::new(),
1831            solver_version: String::new(),
1832            note: String::new(),
1833            source: String::new(),
1834            updated_at: chrono::Utc::now(),
1835            status: None,
1836        }
1837        .with_status()
1838    }
1839
1840    #[test]
1841    fn csv_tables_of_results() {
1842        let csv = b"name,value,unit,limit_low,limit_high,load_case\n\
1843                    Max. von Mises stress,182.5,MPa,,250,LC1 pressure\n\
1844                    Safety factor,1.37,,1.5,,LC1 pressure\n\
1845                    First eigenfrequency,2.35e2,Hz,200,,modal\n\
1846                    ,,,,,\n";
1847        let rows = parse_results_table("results.csv", csv).unwrap();
1848        assert_eq!(rows.len(), 3, "empty rows are skipped");
1849        assert_eq!(rows[0].name, "Max. von Mises stress");
1850        assert_eq!(rows[0].value, 182.5);
1851        assert_eq!(rows[0].upper_limit, Some(250.0));
1852        assert_eq!(rows[0].lower_limit, None);
1853        assert_eq!(rows[0].load_case, "LC1 pressure");
1854        assert_eq!(rows[1].lower_limit, Some(1.5));
1855        assert_eq!(rows[2].value, 235.0, "exponent notation");
1856        let statuses: Vec<_> = rows
1857            .iter()
1858            .map(|r| domain::result_status(r.value, r.lower_limit, r.upper_limit))
1859            .collect();
1860        assert_eq!(
1861            statuses,
1862            [ResultStatus::Pass, ResultStatus::Fail, ResultStatus::Pass]
1863        );
1864
1865        // German Excel: semicolons, decimal commas, German headers, a title row
1866        let german =
1867            "Ergebnisse Halter\nKennwert;Wert;Einheit;Untergrenze;Obergrenze;Lastfall;Löser\n\
1868                      Verschiebung;0,35;mm;;0,5;LF1;CalculiX\n";
1869        let rows = parse_results_table("ergebnisse.csv", german.as_bytes()).unwrap();
1870        assert_eq!(rows.len(), 1);
1871        assert_eq!(rows[0].value, 0.35);
1872        assert_eq!(rows[0].upper_limit, Some(0.5));
1873        assert_eq!(rows[0].solver, "CalculiX");
1874
1875        // min/max and extra columns
1876        let rows = parse_results_table(
1877            "r.tsv",
1878            b"Result\tValue\tUnit\tMin\tMax\tSolver\tSolver version\tNote\n\
1879              dp\t1250 Pa\tPa\t\t1500\tOpenFOAM\tv2312\tsimpleFoam\n",
1880        )
1881        .unwrap();
1882        assert_eq!(rows[0].value, 1250.0, "unit after the number");
1883        assert_eq!(rows[0].solver_version, "v2312");
1884        assert_eq!(rows[0].note, "simpleFoam");
1885
1886        let err = parse_results_table("r.csv", b"name,value\nstress,high\n").unwrap_err();
1887        assert!(err.contains("Row 2") && err.contains("high"), "{err}");
1888        let err = parse_results_table("r.csv", b"name,value\nstress,\n").unwrap_err();
1889        assert!(err.contains("no value"), "{err}");
1890        let err = parse_results_table("r.csv", b"a,b\n1,2\n").unwrap_err();
1891        assert!(err.contains("no header row"), "{err}");
1892        assert!(parse_results_table("r.csv", b"name,value\n").is_err());
1893    }
1894
1895    #[test]
1896    fn input_validation() {
1897        let ok = ResultInput {
1898            name: " Stress ".into(),
1899            value: 1.0,
1900            lower_limit: Some(0.0),
1901            upper_limit: Some(2.0),
1902            ..Default::default()
1903        }
1904        .validated()
1905        .unwrap();
1906        assert_eq!(ok.name, "Stress");
1907        assert!(ResultInput {
1908            name: "".into(),
1909            value: 1.0,
1910            ..Default::default()
1911        }
1912        .validated()
1913        .is_err());
1914        assert!(ResultInput {
1915            name: "x".into(),
1916            value: 1.0,
1917            lower_limit: Some(3.0),
1918            upper_limit: Some(2.0),
1919            ..Default::default()
1920        }
1921        .validated()
1922        .is_err());
1923        // JSON of solver scripts: limit_low / limit_high are accepted
1924        let input: ResultInput = serde_json::from_value(
1925            serde_json::json!({ "name": "dp", "value": 3.5, "limit_high": 4.0 }),
1926        )
1927        .unwrap();
1928        assert_eq!(input.upper_limit, Some(4.0));
1929    }
1930
1931    #[test]
1932    fn comparison_and_csv_export() {
1933        let before = vec![
1934            result("Stress", "LC1", 200.0, Some(250.0)),
1935            result("Stress", "LC2", 150.0, Some(250.0)),
1936            result("Mass", "", 1.0, None),
1937        ];
1938        let after = vec![
1939            result("stress", "lc1", 180.0, Some(250.0)),
1940            result("Stress", "LC2", 150.0, Some(250.0)),
1941            result("Frequency", "modal", 240.0, None),
1942        ];
1943        let diffs = compare_results(&before, &after);
1944        let s = compare_summary(&diffs);
1945        assert_eq!((s.added, s.removed, s.changed, s.unchanged), (1, 1, 1, 1));
1946        let changed = diffs.iter().find(|d| d.change == Change::Changed).unwrap();
1947        assert_eq!(changed.delta, Some(-20.0));
1948        assert_eq!(changed.delta_percent, Some(-10.0));
1949        assert_eq!(
1950            diffs
1951                .iter()
1952                .find(|d| d.change == Change::Removed)
1953                .unwrap()
1954                .name,
1955            "Mass"
1956        );
1957
1958        let summary = summarize(&before);
1959        assert_eq!(
1960            (summary.pass, summary.fail, summary.without_limits),
1961            (2, 0, 1)
1962        );
1963        let failing = vec![result("Stress, max", "LC1", 300.0, Some(250.0))];
1964        assert_eq!(summarize(&failing).fail, 1);
1965        let csv = to_csv(&failing);
1966        assert!(csv.starts_with("name,value,unit,limit_low,limit_high,load_case"));
1967        assert!(
1968            csv.contains("\"Stress, max\",300,MPa,,250,LC1,,,,fail"),
1969            "{csv}"
1970        );
1971        // the export can be imported again
1972        let rows = parse_results_table("export.csv", csv.as_bytes()).unwrap();
1973        assert_eq!(rows[0].name, "Stress, max");
1974        assert_eq!(rows[0].upper_limit, Some(250.0));
1975    }
1976
1977    fn basis() -> SimulationBasis {
1978        SimulationBasis {
1979            id: Uuid::nil(),
1980            revision_id: Uuid::nil(),
1981            role: "geometry".into(),
1982            note: String::new(),
1983            basis_item_id: Uuid::nil(),
1984            basis_number: "PRT-000001".into(),
1985            basis_type: "part".into(),
1986            basis_revision_id: Uuid::nil(),
1987            basis_code: "A".into(),
1988            basis_seq: 1,
1989            basis_state: "released".into(),
1990            basis_name: "Bracket".into(),
1991            file_version_id: None,
1992            file_id: None,
1993            file_name: String::new(),
1994            file_version: None,
1995            file_current_version: None,
1996            released_revision_id: None,
1997            released_code: Some("A".into()),
1998            released_seq: Some(1),
1999            created_at: chrono::Utc::now(),
2000            out_of_date: None,
2001            basis_project_id: None,
2002            restricted: false,
2003        }
2004    }
2005
2006    #[test]
2007    fn out_of_date_rules() {
2008        assert_eq!(out_of_date_reason(&basis()), None);
2009        let newer = SimulationBasis {
2010            released_code: Some("B".into()),
2011            released_seq: Some(2),
2012            ..basis()
2013        };
2014        assert_eq!(
2015            out_of_date_reason(&newer).as_deref(),
2016            Some("PRT-000001/B was released (simulated: A)")
2017        );
2018        let obsolete = SimulationBasis {
2019            basis_state: "obsolete".into(),
2020            ..basis()
2021        };
2022        assert!(out_of_date_reason(&obsolete).unwrap().contains("obsolete"));
2023        let pinned = SimulationBasis {
2024            basis_state: "in_work".into(),
2025            released_code: None,
2026            released_seq: None,
2027            file_version_id: Some(Uuid::nil()),
2028            file_name: "bracket.step".into(),
2029            file_version: Some(2),
2030            file_current_version: Some(2),
2031            ..basis()
2032        };
2033        assert_eq!(out_of_date_reason(&pinned), None);
2034        let newer_file = SimulationBasis {
2035            file_current_version: Some(3),
2036            ..pinned.clone()
2037        };
2038        assert_eq!(
2039            out_of_date_reason(&newer_file).as_deref(),
2040            Some("bracket.step has version 3 (simulated: v2)")
2041        );
2042        let deleted = SimulationBasis {
2043            file_version_id: None,
2044            file_current_version: None,
2045            ..pinned
2046        };
2047        assert!(out_of_date_reason(&deleted).unwrap().contains("deleted"));
2048    }
2049}
2050
2051// ---------------------------------------------------------------------------
2052// Simulations that need attention (dashboard)
2053// ---------------------------------------------------------------------------
2054
2055/// SQL condition (alias `sr` = `simulation_results`): the value is outside
2056/// its limits. Only values with a limit can fail (see
2057/// `domain::result_status`).
2058pub(crate) const RESULT_FAILED_SQL: &str =
2059    "((sr.lower_limit IS NOT NULL AND sr.value < sr.lower_limit)
2060      OR (sr.upper_limit IS NOT NULL AND sr.value > sr.upper_limit))";
2061
2062/// At most this many simulations are checked for the dashboard card.
2063pub const MAX_ATTENTION_SCAN: i64 = 2000;
2064
2065/// A simulation whose latest revision has failed result values or is out of
2066/// date.
2067#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
2068pub struct AttentionSimulation {
2069    pub item_id: Uuid,
2070    pub number: String,
2071    pub name: String,
2072    pub revision_id: Uuid,
2073    pub revision_code: String,
2074    pub state: String,
2075    pub failed_count: i64,
2076    pub result_count: i64,
2077    /// Why the simulation is out of date; empty = up to date. Bases the
2078    /// actor may not see never contribute.
2079    #[sqlx(skip)]
2080    pub out_of_date: Vec<String>,
2081}
2082
2083/// Simulations the actor may read whose latest revision (not obsolete) has a
2084/// value outside its limits or an out-of-date basis, the first `limit` by
2085/// number, how many there are in total, and whether the scan cap was hit
2086/// (the total is then a lower bound).
2087pub async fn needing_attention(
2088    pool: &PgPool,
2089    actor: &Actor,
2090    limit: usize,
2091) -> AppResult<(Vec<AttentionSimulation>, usize, bool)> {
2092    let mut sims = sqlx::query_as::<_, AttentionSimulation>(&format!(
2093        "SELECT i.id AS item_id, i.number, lr.name, lr.id AS revision_id, lr.code AS revision_code,
2094                lr.state,
2095                (SELECT COUNT(*) FROM simulation_results sr
2096                 WHERE sr.revision_id = lr.id AND {RESULT_FAILED_SQL}) AS failed_count,
2097                (SELECT COUNT(*) FROM simulation_results sr
2098                 WHERE sr.revision_id = lr.id) AS result_count
2099         FROM items i
2100         CROSS JOIN LATERAL (
2101             SELECT r.id, r.code, r.state, r.name FROM revisions r
2102             WHERE r.item_id = i.id ORDER BY r.seq DESC LIMIT 1
2103         ) lr
2104         WHERE i.item_type = 'simulation' AND lr.state <> 'obsolete' AND {}
2105           AND (EXISTS (SELECT 1 FROM simulation_results sr
2106                        WHERE sr.revision_id = lr.id AND {RESULT_FAILED_SQL})
2107                OR EXISTS (SELECT 1 FROM simulation_bases sb WHERE sb.revision_id = lr.id))
2108         ORDER BY i.number LIMIT $2",
2109        access::item_visible("i", 1)
2110    ))
2111    .bind(&actor.hidden_projects)
2112    .bind(MAX_ATTENTION_SCAN)
2113    .fetch_all(pool)
2114    .await?;
2115    let capped = sims.len() as i64 >= MAX_ATTENTION_SCAN;
2116    let ids: Vec<Uuid> = sims.iter().map(|s| s.revision_id).collect();
2117    let bases = sqlx::query_as::<_, SimulationBasis>(&format!(
2118        "{BASIS_SELECT} WHERE sb.revision_id = ANY($1)"
2119    ))
2120    .bind(&ids)
2121    .fetch_all(pool)
2122    .await?;
2123    let mut reasons: HashMap<Uuid, Vec<String>> = HashMap::new();
2124    for b in bases {
2125        let revision_id = b.revision_id;
2126        // a hidden basis is masked first: it tells nothing about the item
2127        if let Some(reason) = with_status(b).redact(actor).out_of_date {
2128            reasons.entry(revision_id).or_default().push(reason);
2129        }
2130    }
2131    for s in &mut sims {
2132        s.out_of_date = reasons.remove(&s.revision_id).unwrap_or_default();
2133    }
2134    sims.retain(|s| s.failed_count > 0 || !s.out_of_date.is_empty());
2135    let total = sims.len();
2136    sims.truncate(limit);
2137    Ok((sims, total, capped))
2138}