1use 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
41pub const MAX_RESULTS: usize = 2000;
43
44#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
50pub struct SimulationBasis {
51 pub id: Uuid,
52 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 pub file_version_id: Option<Uuid>,
67 pub file_id: Option<Uuid>,
68 pub file_name: String,
69 pub file_version: Option<i32>,
70 pub file_current_version: Option<i32>,
72 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 #[sqlx(default)]
80 pub out_of_date: Option<String>,
81 #[serde(skip)]
83 pub basis_project_id: Option<Uuid>,
84 #[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 self.note = String::new();
109 self.out_of_date = None;
112 self.basis_project_id = None;
113 self.restricted = true;
114 }
115
116 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
125async 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
150pub 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
182pub 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 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
209async 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#[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 pub result_count: i64,
234 pub failed_count: i64,
235 pub open_impact_id: Option<Uuid>,
237 #[sqlx(flatten)]
239 pub basis: SimulationBasis,
240}
241
242pub 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
303pub(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 pub item_number: String,
319 pub revision_code: String,
321 pub file_name: String,
323 pub file_version_id: Option<Uuid>,
325 pub role: BasisRole,
326 pub note: String,
327}
328
329pub 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, ¬e).await?;
363 tx.commit().await?;
364 get_basis(pool, actor, id).await
365}
366
367async 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 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 #[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
493pub fn default_name(basis_name: &str) -> String {
496 let name = format!("Simulation – {}", basis_name.trim());
497 name.chars().take(200).collect()
498}
499
500pub 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
510pub 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#[derive(Clone, Debug, Serialize)]
526pub struct BasisPrefill {
527 pub revision_id: Uuid,
528 pub item_number: String,
529 pub code: String,
530 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
539pub 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
562pub 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
579pub 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
642pub 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
650pub 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
694pub(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
729pub(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#[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 pub source: String,
762 pub updated_at: chrono::DateTime<chrono::Utc>,
763 #[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
788pub 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#[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#[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 #[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
891pub(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
960pub 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
1026pub 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
1102pub 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#[derive(Clone, Debug, Default, Serialize)]
1143pub struct ImportSummary {
1144 pub added: usize,
1145 pub updated: usize,
1146 pub removed: usize,
1147}
1148
1149pub 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
1204pub(crate) enum Origin {
1206 Import,
1208 Exchange(String),
1210 Templates(Vec<String>),
1212 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
1247pub(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 #[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
1366pub(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 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
1447fn 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
1465fn 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
1484fn 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
1511pub(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 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
1533pub 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
1608pub 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#[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 pub delta: Option<f64>,
1649 pub delta_percent: Option<f64>,
1650}
1651
1652pub 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#[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
1735pub 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
1753pub const GALLERY_MAX_BYTES: i64 = 8 * 1024 * 1024;
1755
1756pub const GALLERY_MAX_IMAGES: usize = 12;
1758
1759pub 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 assert_eq!(pick_basis_file(&[f("notes.txt", "")]), None);
1799 let files = [f("a.ipt", "inventor"), f("z.step", "step"), f("n.txt", "")];
1801 assert_eq!(pick_basis_file(&files), Some("z.step"));
1802 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 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 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 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 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
2051pub(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
2062pub const MAX_ATTENTION_SCAN: i64 = 2000;
2064
2065#[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 #[sqlx(skip)]
2080 pub out_of_date: Vec<String>,
2081}
2082
2083pub 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 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}