1use chrono::Utc;
4use serde_json::json;
5use sqlx::{PgConnection, PgPool};
6use uuid::Uuid;
7
8use crate::{
9 auth::Actor,
10 domain::{self, Classification, ItemType, LifecycleState, ProjectVisibility, Role},
11 error::{AppError, AppResult},
12 models::{Item, ItemSummary, Revision, WhereUsed, WhereUsedLevel},
13 services::{
14 access, attributes, audit, files, notifications, projects, revisions, simulations, Page,
15 },
16};
17
18pub(crate) const ITEM_SELECT: &str = "
19 SELECT i.id, i.number, i.item_type, i.unit, i.project_id, p.code AS project_code,
20 i.created_by, u.display_name AS created_by_name, i.created_at, i.classification,
21 i.owner_id, ou.display_name AS owner_name
22 FROM items i
23 LEFT JOIN projects p ON p.id = i.project_id
24 JOIN users u ON u.id = i.created_by
25 JOIN users ou ON ou.id = i.owner_id";
26
27#[derive(Clone, Debug, Default)]
28pub struct ItemFilter {
29 pub query: Option<String>,
31 pub item_type: Option<ItemType>,
32 pub project_id: Option<Uuid>,
33 pub state: Option<LifecycleState>,
35 pub system: Option<String>,
38 pub classification: Option<Classification>,
39 pub attribute: Option<String>,
42 pub attribute_value: Option<String>,
43 pub result: Option<domain::ResultFilter>,
47 pub result_name: Option<String>,
48}
49
50type AttributeBinds = (
52 Option<String>,
53 Option<String>,
54 Option<bool>,
55 Option<f64>,
56 Option<f64>,
57);
58
59async fn attribute_binds(pool: &PgPool, filter: &ItemFilter) -> AppResult<AttributeBinds> {
60 let Some(key) = filter.attribute.as_deref().filter(|k| !k.trim().is_empty()) else {
61 return Ok((None, None, None, None, None));
62 };
63 let def = attributes::get_by_key(pool, key)
64 .await
65 .map_err(|_| AppError::BadRequest(format!("Unknown attribute '{}'.", key.trim())))?;
66 let data_type =
67 domain::AttributeType::parse(&def.data_type).unwrap_or(domain::AttributeType::Text);
68 let condition = domain::parse_attribute_condition(
69 &def.label,
70 data_type,
71 filter.attribute_value.as_deref().unwrap_or(""),
72 )?;
73 let key = Some(def.key);
74 Ok(match condition {
75 domain::AttributeCondition::Present => (key, None, None, None, None),
76 domain::AttributeCondition::Contains(text) => {
77 (key, Some(domain::like_pattern(&text)), None, None, None)
78 }
79 domain::AttributeCondition::Bool(b) => (key, None, Some(b), None, None),
80 domain::AttributeCondition::Range(min, max) => (key, None, None, min, max),
81 })
82}
83
84pub async fn search(
94 pool: &PgPool,
95 actor: &Actor,
96 filter: &ItemFilter,
97 page: Page,
98) -> AppResult<(Vec<ItemSummary>, i64)> {
99 const JOINS: &str = "
109 LEFT JOIN projects p ON p.id = i.project_id
110 CROSS JOIN LATERAL (
111 SELECT r.id, r.code, r.state, r.name, r.description, r.material, r.search_vector,
112 r.attributes
113 FROM revisions r WHERE r.item_id = i.id ORDER BY r.seq DESC LIMIT 1
114 ) lr";
115 const FILE_MATCH: &str = "
120 FROM files f
121 JOIN file_versions fv ON fv.file_id = f.id AND fv.version = f.current_version
122 WHERE $1::text IS NOT NULL AND f.revision_id = lr.id
123 AND (f.filename ILIKE $1
124 OR ($5::text IS NOT NULL AND fv.content_vector @@ to_tsquery('simple', $5))
125 OR f.format = ANY($6::text[]))";
126 const RESULT_MATCH: &str = "($18::text IS NULL OR sr.name ILIKE $18)
128 AND ($17::text IS NULL
129 OR ($17 = 'failed' AND (RESULT_FAILED))
130 OR ($17 = 'passed' AND (sr.lower_limit IS NOT NULL OR sr.upper_limit IS NOT NULL)
131 AND NOT (RESULT_FAILED))
132 OR ($17 = 'above_limit' AND sr.upper_limit IS NOT NULL AND sr.value > sr.upper_limit)
133 OR ($17 = 'below_limit' AND sr.lower_limit IS NOT NULL AND sr.value < sr.lower_limit))";
134 let result_match = RESULT_MATCH.replace("RESULT_FAILED", simulations::RESULT_FAILED_SQL);
135 let visible = access::item_visible("i", 10);
136 let item_conditions = format!(
138 "($2::text IS NULL OR i.item_type = $2)
139 AND ($3::uuid IS NULL OR i.project_id = $3)
140 AND ($11::text IS NULL OR i.classification = $11)
141 AND {visible}"
142 );
143 let where_ = format!(
144 "($1::text IS NULL
145 OR i.number ILIKE $1 OR lr.name ILIKE $1 OR lr.description ILIKE $1 OR lr.material ILIKE $1
146 OR jsonb_path_query_array(lr.attributes, '$.*')::text ILIKE $1
147 OR ($5::text IS NOT NULL AND lr.search_vector @@ to_tsquery('simple', $5))
148 OR EXISTS (SELECT 1 {FILE_MATCH}))
149 AND {item_conditions}
150 AND ($4::text IS NULL OR lr.state = $4)
151 AND ($7::text[] IS NULL OR EXISTS (
152 SELECT 1 FROM files sf WHERE sf.revision_id = lr.id AND sf.format = ANY($7::text[])))
153 AND ($12::text IS NULL OR (lr.attributes ? $12
154 AND ($13::text IS NULL OR lr.attributes ->> $12 ILIKE $13)
155 AND ($14::boolean IS NULL OR lr.attributes -> $12 = to_jsonb($14::boolean))
156 AND ($15::float8 IS NULL OR CASE WHEN jsonb_typeof(lr.attributes -> $12) = 'number'
157 THEN (lr.attributes ->> $12)::float8 >= $15 ELSE FALSE END)
158 AND ($16::float8 IS NULL OR CASE WHEN jsonb_typeof(lr.attributes -> $12) = 'number'
159 THEN (lr.attributes ->> $12)::float8 <= $16 ELSE FALSE END)))
160 AND ($17::text IS NULL AND $18::text IS NULL OR EXISTS (
161 SELECT 1 FROM simulation_results sr WHERE sr.revision_id = lr.id AND {result_match}))"
162 );
163 let columns = format!(
164 "i.id, i.number, i.item_type, i.unit, p.code AS project_code, i.created_at,
165 lr.id AS latest_revision_id, lr.code AS latest_code, lr.state AS latest_state, lr.name,
166 (SELECT rr.code FROM revisions rr WHERE rr.item_id = i.id AND rr.state = 'released'
167 ORDER BY rr.seq DESC LIMIT 1) AS released_code,
168 (SELECT f.filename {FILE_MATCH} ORDER BY f.filename LIMIT 1) AS matched_file,
169 i.classification, lr.attributes"
170 );
171 const ORDER: &str = "ORDER BY CASE WHEN $5::text IS NULL THEN 0
172 ELSE ts_rank(lr.search_vector, to_tsquery('simple', $5)) END DESC,
173 i.number";
174 const TEXT_CANDIDATES: &str = "
176 SELECT ci.id FROM items ci WHERE ci.number ILIKE $1
177 UNION
178 SELECT cr.item_id FROM revisions cr
179 WHERE cr.name ILIKE $1 OR cr.description ILIKE $1 OR cr.material ILIKE $1
180 OR jsonb_path_query_array(cr.attributes, '$.*')::text ILIKE $1
181 OR ($5::text IS NOT NULL AND cr.search_vector @@ to_tsquery('simple', $5))
182 UNION
183 SELECT cr.item_id FROM files cf JOIN revisions cr ON cr.id = cf.revision_id
184 WHERE cf.filename ILIKE $1 OR cf.format = ANY($6::text[])
185 UNION
186 SELECT cr.item_id FROM file_versions cv
187 JOIN files cf ON cf.id = cv.file_id AND cv.version = cf.current_version
188 JOIN revisions cr ON cr.id = cf.revision_id
189 WHERE $5::text IS NOT NULL AND cv.content_vector @@ to_tsquery('simple', $5)";
190 const STATE_CANDIDATES: &str = "SELECT cr.item_id FROM revisions cr WHERE cr.state = $4";
191 const SYSTEM_CANDIDATES: &str = "
192 SELECT cr.item_id FROM files cf JOIN revisions cr ON cr.id = cf.revision_id
193 WHERE cf.format = ANY($7::text[])";
194 const ATTRIBUTE_CANDIDATES: &str =
195 "SELECT cr.item_id FROM revisions cr WHERE cr.attributes ? $12";
196 let result_candidates = format!(
197 "SELECT cr.item_id FROM simulation_results sr JOIN revisions cr ON cr.id = sr.revision_id
198 WHERE {result_match}"
199 );
200
201 let classification = filter.classification.map(Classification::as_str);
202 let (attr_key, attr_pattern, attr_bool, attr_min, attr_max) =
203 attribute_binds(pool, filter).await?;
204 let query = filter
205 .query
206 .as_deref()
207 .map(str::trim)
208 .filter(|q| !q.is_empty());
209 let pattern = query.map(domain::like_pattern);
210 let tsquery = query.and_then(crate::textindex::prefix_query);
211 let result_state = filter.result.map(domain::ResultFilter::as_str);
212 let result_pattern = filter
213 .result_name
214 .as_deref()
215 .map(str::trim)
216 .filter(|n| !n.is_empty())
217 .map(domain::like_pattern);
218 let item_type = filter.item_type.map(ItemType::as_str);
219 let state = filter.state.map(LifecycleState::as_str);
220 let named_formats = query
221 .map(crate::cadformat::ids_named_by)
222 .unwrap_or_default();
223 let system_formats = filter
224 .system
225 .as_deref()
226 .filter(|s| !s.is_empty())
227 .map(crate::cadformat::ids_of_system);
228
229 let mut narrow: Vec<&str> = Vec::new();
230 if pattern.is_some() {
231 narrow.push(TEXT_CANDIDATES);
232 }
233 if state.is_some() {
234 narrow.push(STATE_CANDIDATES);
235 }
236 if system_formats.is_some() {
237 narrow.push(SYSTEM_CANDIDATES);
238 }
239 if attr_key.is_some() {
240 narrow.push(ATTRIBUTE_CANDIDATES);
241 }
242 if result_state.is_some() || result_pattern.is_some() {
243 narrow.push(&result_candidates);
244 }
245 let candidates: String = narrow
246 .iter()
247 .map(|c| format!(" AND i.id IN ({c})"))
248 .collect();
249
250 #[derive(sqlx::FromRow)]
251 struct Row {
252 #[sqlx(flatten)]
253 item: ItemSummary,
254 total: i64,
255 }
256 let rows_sql = if narrow.is_empty() {
257 format!(
261 "WITH page AS (
262 SELECT i.id FROM items i
263 WHERE {item_conditions}
264 ORDER BY i.number LIMIT $8 OFFSET $9
265 )
266 SELECT {columns}, 0::bigint AS total
267 FROM page JOIN items i ON i.id = page.id {JOINS}
268 WHERE {where_}
269 {ORDER}"
270 )
271 } else {
272 format!(
273 "SELECT {columns}, COUNT(*) OVER () AS total
274 FROM items i {JOINS}
275 WHERE {where_} {candidates}
276 {ORDER}
277 LIMIT $8 OFFSET $9"
278 )
279 };
280 let rows = sqlx::query_as::<_, Row>(&rows_sql)
281 .bind(&pattern)
282 .bind(item_type)
283 .bind(filter.project_id)
284 .bind(state)
285 .bind(&tsquery)
286 .bind(&named_formats)
287 .bind(&system_formats)
288 .bind(page.limit)
289 .bind(page.offset)
290 .bind(&actor.hidden_projects)
291 .bind(classification)
292 .bind(&attr_key)
293 .bind(&attr_pattern)
294 .bind(attr_bool)
295 .bind(attr_min)
296 .bind(attr_max)
297 .bind(result_state)
298 .bind(&result_pattern)
299 .fetch_all(pool)
300 .await?;
301 let total = match rows.first() {
304 _ if narrow.is_empty() => {
305 sqlx::query_scalar::<_, i64>(&format!(
306 "SELECT COUNT(*) FROM items i
307 WHERE ($1::text IS NULL OR i.item_type = $1) AND ($2::uuid IS NULL OR i.project_id = $2)
308 AND ($4::text IS NULL OR i.classification = $4)
309 AND {}",
310 access::item_visible("i", 3)
311 ))
312 .bind(item_type)
313 .bind(filter.project_id)
314 .bind(&actor.hidden_projects)
315 .bind(classification)
316 .fetch_one(pool)
317 .await?
318 }
319 Some(row) => row.total,
320 None if page.offset == 0 => 0,
321 None => {
322 sqlx::query_scalar::<_, i64>(&format!(
324 "SELECT COUNT(*) FROM items i {JOINS} WHERE {where_} {candidates}
325 AND $8::bigint IS NOT NULL AND $9::bigint IS NOT NULL"
326 ))
327 .bind(&pattern)
328 .bind(item_type)
329 .bind(filter.project_id)
330 .bind(state)
331 .bind(&tsquery)
332 .bind(&named_formats)
333 .bind(&system_formats)
334 .bind(page.limit)
335 .bind(page.offset)
336 .bind(&actor.hidden_projects)
337 .bind(classification)
338 .bind(&attr_key)
339 .bind(&attr_pattern)
340 .bind(attr_bool)
341 .bind(attr_min)
342 .bind(attr_max)
343 .bind(result_state)
344 .bind(&result_pattern)
345 .fetch_one(pool)
346 .await?
347 }
348 };
349 Ok((rows.into_iter().map(|r| r.item).collect(), total))
350}
351
352pub(crate) async fn get_unchecked(pool: impl sqlx::PgExecutor<'_>, id: Uuid) -> AppResult<Item> {
353 sqlx::query_as::<_, Item>(&format!("{ITEM_SELECT} WHERE i.id = $1"))
354 .bind(id)
355 .fetch_optional(pool)
356 .await?
357 .ok_or_else(|| AppError::not_found("Item not found."))
358}
359
360pub(crate) async fn get_by_number_unchecked(pool: &PgPool, number: &str) -> AppResult<Item> {
361 let number = number.trim().to_ascii_uppercase();
362 sqlx::query_as::<_, Item>(&format!("{ITEM_SELECT} WHERE i.number = $1"))
363 .bind(&number)
364 .fetch_optional(pool)
365 .await?
366 .ok_or_else(|| AppError::not_found(format!("Item '{number}' not found.")))
367}
368
369pub(crate) async fn revisions_unchecked(pool: &PgPool, item_id: Uuid) -> AppResult<Vec<Revision>> {
371 Ok(sqlx::query_as::<_, Revision>(&format!(
372 "{} WHERE r.item_id = $1 ORDER BY r.seq DESC",
373 revisions::REVISION_SELECT
374 ))
375 .bind(item_id)
376 .fetch_all(pool)
377 .await?)
378}
379
380pub(crate) async fn latest_revision_unchecked(pool: &PgPool, item_id: Uuid) -> AppResult<Revision> {
381 sqlx::query_as::<_, Revision>(&format!(
382 "{} WHERE r.item_id = $1 ORDER BY r.seq DESC LIMIT 1",
383 revisions::REVISION_SELECT
384 ))
385 .bind(item_id)
386 .fetch_optional(pool)
387 .await?
388 .ok_or_else(access::not_found_item)
391}
392
393pub(crate) async fn open_revision_unchecked(
395 pool: &PgPool,
396 item_id: Uuid,
397) -> AppResult<Option<Revision>> {
398 Ok(sqlx::query_as::<_, Revision>(&format!(
399 "{} WHERE r.item_id = $1 AND r.state IN ('in_work', 'in_review')",
400 revisions::REVISION_SELECT
401 ))
402 .bind(item_id)
403 .fetch_optional(pool)
404 .await?)
405}
406
407pub async fn get(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Item> {
409 let item = get_unchecked(pool, id).await?;
410 access::ensure_visible(actor, item.project_id, access::not_found_item())?;
411 Ok(item)
412}
413
414pub async fn get_by_number(pool: &PgPool, actor: &Actor, number: &str) -> AppResult<Item> {
417 let item = get_by_number_unchecked(pool, number).await?;
418 access::ensure_visible(
419 actor,
420 item.project_id,
421 AppError::not_found(format!(
422 "Item '{}' not found.",
423 number.trim().to_ascii_uppercase()
424 )),
425 )?;
426 Ok(item)
427}
428
429pub async fn revisions(pool: &PgPool, actor: &Actor, item_id: Uuid) -> AppResult<Vec<Revision>> {
431 let revs = revisions_unchecked(pool, item_id).await?;
432 if let Some(first) = revs.first() {
433 access::ensure_visible(actor, first.project_id, access::not_found_item())?;
434 }
435 Ok(revs)
436}
437
438pub async fn latest_revision(pool: &PgPool, actor: &Actor, item_id: Uuid) -> AppResult<Revision> {
440 let rev = latest_revision_unchecked(pool, item_id).await?;
441 access::ensure_visible(actor, rev.project_id, access::not_found_item())?;
442 Ok(rev)
443}
444
445pub async fn open_revision(
447 pool: &PgPool,
448 actor: &Actor,
449 item_id: Uuid,
450) -> AppResult<Option<Revision>> {
451 let rev = open_revision_unchecked(pool, item_id).await?;
452 if let Some(rev) = &rev {
453 access::ensure_visible(actor, rev.project_id, access::not_found_item())?;
454 }
455 Ok(rev)
456}
457
458#[derive(Clone, Debug)]
459pub struct NewItem {
460 pub number: Option<String>,
462 pub item_type: ItemType,
463 pub unit: String,
464 pub project_id: Option<Uuid>,
465 pub name: String,
466 pub description: String,
467 pub material: String,
468 pub mass_kg: Option<f64>,
469 pub unit_cost: Option<f64>,
470 pub classification: Classification,
472 pub attributes: Vec<(String, String)>,
474}
475
476fn validate_unit(unit: &str) -> AppResult<String> {
477 let unit = unit.trim();
478 if unit.is_empty() {
479 return Ok("each".into());
480 }
481 domain::limit_text("Unit", unit, 16)
482}
483
484pub async fn create(pool: &PgPool, actor: &Actor, input: NewItem) -> AppResult<Revision> {
504 let mut tx = pool.begin().await?;
505 let created = create_tx(&mut tx, actor, input).await?;
506 tx.commit().await?;
507 revisions::get(pool, actor, created.revision_id).await
508}
509
510pub const MAX_IDEMPOTENCY_KEY: usize = 255;
512
513pub fn validate_idempotency_key(key: &str) -> AppResult<&str> {
515 let key = key.trim();
516 if key.is_empty()
517 || key.len() > MAX_IDEMPOTENCY_KEY
518 || !key.bytes().all(|b| (0x21..=0x7e).contains(&b))
519 {
520 return Err(AppError::BadRequest(format!(
521 "idempotency_key must be 1 to {MAX_IDEMPOTENCY_KEY} printable ASCII characters without spaces."
522 )));
523 }
524 Ok(key)
525}
526
527pub async fn create_idempotent(
542 pool: &PgPool,
543 actor: &Actor,
544 input: NewItem,
545 key: &str,
546 payload_hash: &str,
547) -> AppResult<(Revision, bool)> {
548 let key = validate_idempotency_key(key)?;
549 let mut tx = pool.begin().await?;
550 let inserted = sqlx::query(
551 "INSERT INTO item_idempotency (user_id, key, payload_hash) VALUES ($1, $2, $3)
552 ON CONFLICT (user_id, key) DO NOTHING",
553 )
554 .bind(actor.id)
555 .bind(key)
556 .bind(payload_hash)
557 .execute(&mut *tx)
558 .await?
559 .rows_affected();
560 if inserted == 0 {
561 let (hash, revision_id) = sqlx::query_as::<_, (String, Option<Uuid>)>(
562 "SELECT payload_hash, revision_id FROM item_idempotency WHERE user_id = $1 AND key = $2",
563 )
564 .bind(actor.id)
565 .bind(key)
566 .fetch_one(&mut *tx)
567 .await?;
568 drop(tx);
569 if hash != payload_hash {
570 return Err(AppError::Conflict(
571 "This idempotency key was used for a different request; use a new key for a different item.".into(),
572 ));
573 }
574 let revision_id = revision_id.ok_or_else(|| {
575 AppError::Conflict("The request with this idempotency key is still running.".into())
576 })?;
577 return Ok((revisions::get(pool, actor, revision_id).await?, true));
578 }
579 let created = create_tx(&mut tx, actor, input).await?;
580 sqlx::query("UPDATE item_idempotency SET revision_id = $3 WHERE user_id = $1 AND key = $2")
581 .bind(actor.id)
582 .bind(key)
583 .bind(created.revision_id)
584 .execute(&mut *tx)
585 .await?;
586 tx.commit().await?;
587 Ok((
588 revisions::get(pool, actor, created.revision_id).await?,
589 false,
590 ))
591}
592
593pub async fn create_with_files(
608 pool: &PgPool,
609 actor: &Actor,
610 input: NewItem,
611 uploads: &[files::Upload],
612) -> AppResult<Revision> {
613 let mut tx = pool.begin().await?;
614 let created = create_tx(&mut tx, actor, input).await?;
615 if !uploads.is_empty() {
616 let rev = revisions::lock(&mut tx, created.revision_id).await?;
617 files::store_uploads(&mut tx, actor, &rev, uploads, "").await?;
618 }
619 tx.commit().await?;
620 revisions::get(pool, actor, created.revision_id).await
621}
622
623#[derive(Clone, Debug)]
625pub(crate) struct Created {
626 pub item_id: Uuid,
627 pub revision_id: Uuid,
628 pub number: String,
629}
630
631pub(crate) async fn create_tx(
634 conn: &mut PgConnection,
635 actor: &Actor,
636 input: NewItem,
637) -> AppResult<Created> {
638 actor.require(Role::Editor)?;
639 let attrs = revisions::RevisionUpdate {
640 name: input.name,
641 description: input.description,
642 material: input.material,
643 mass_kg: input.mass_kg,
644 unit_cost: input.unit_cost,
645 attributes: input.attributes,
646 }
647 .validated()?;
648 let unit = validate_unit(&input.unit)?;
649 let attribute_values = attributes::apply_changes(
650 &mut *conn,
651 input.item_type.as_str(),
652 &serde_json::json!({}),
653 &attrs.attributes,
654 )
655 .await?;
656 let manual_number = match input.number.as_deref().map(str::trim) {
657 Some(n) if !n.is_empty() => Some(domain::normalize_item_number(n)?),
658 _ => None,
659 };
660
661 let project_id = input.project_id.ok_or_else(|| {
663 AppError::BadRequest("Choose the project group the item belongs to.".into())
664 })?;
665 let tx = conn;
666 let (code, active, scheme, visibility) =
667 sqlx::query_as::<_, (String, bool, Option<String>, String)>(
668 "SELECT code, active, number_scheme, visibility FROM projects WHERE id = $1 FOR SHARE",
670 )
671 .bind(project_id)
672 .fetch_optional(&mut *tx)
673 .await?
674 .ok_or_else(access::unknown_project)?;
675 access::ensure_visible(actor, Some(project_id), access::unknown_project())?;
676 if !active {
677 return Err(AppError::BadRequest(format!(
678 "The project group {code} is archived."
679 )));
680 }
681 if !actor.can_edit_project(Some(project_id)) {
682 return Err(AppError::Forbidden(format!(
683 "You are not a member of the project group {code}; ask an admin to add you."
684 )));
685 }
686
687 let number = match manual_number {
688 Some(n) => n,
689 None => {
690 let scheme = domain::NumberScheme::of_project(scheme.as_deref())?;
693 let ctx = domain::number_context(&code, input.item_type, Utc::now());
694 let (counter, seed) = projects::number_counter(project_id, &visibility, &scheme, &ctx);
695 let own_scheme = scheme != domain::NumberScheme::default_scheme();
696 let mut attempts = 0;
697 let mut jumped = false;
698 loop {
699 let next = projects::next_number(tx, &counter, seed.as_deref()).await?;
700 let generated = scheme.format(&ctx, next);
701 let candidate = domain::normalize_item_number(&generated).map_err(|_| {
702 AppError::BadRequest(format!(
703 "The numbering scheme of the project group {code} gives the invalid item number '{generated}'; enter a number or change the scheme."
704 ))
705 })?;
706 let taken = sqlx::query_scalar::<_, Option<Uuid>>(
708 "SELECT project_id FROM items WHERE number = $1",
709 )
710 .bind(&candidate)
711 .fetch_optional(&mut *tx)
712 .await?;
713 let Some(holder) = taken else {
714 break candidate;
715 };
716 if own_scheme && !actor.can_see_project(holder) {
726 return Err(AppError::Conflict(format!(
727 "The numbering scheme of the project group {code} gives the item number '{candidate}', which is already in use; change the scheme or enter a number."
728 )));
729 }
730 if !jumped {
731 jumped = true;
738 let (prefix, _) = scheme.around_seq(&ctx);
739 let pattern = format!(
740 "{}%",
741 prefix
742 .replace('\\', "\\\\")
743 .replace('%', "\\%")
744 .replace('_', "\\_")
745 );
746 let highest = sqlx::query_as::<_, (String, Option<Uuid>)>(
747 "SELECT number, project_id FROM items WHERE number LIKE $1 ESCAPE '\\'",
748 )
749 .bind(&pattern)
750 .fetch_all(&mut *tx)
751 .await?
752 .iter()
753 .filter(|(_, project)| actor.can_see_project(*project))
754 .filter_map(|(n, _)| scheme.seq_of(&ctx, n))
755 .max();
756 if let Some(highest) = highest {
757 sqlx::query(
758 "UPDATE counters SET value = GREATEST(value, $2) WHERE name = $1",
759 )
760 .bind(&counter)
761 .bind(highest)
762 .execute(&mut *tx)
763 .await?;
764 }
765 continue;
766 }
767 attempts += 1;
768 if attempts > 1000 {
769 return Err(AppError::Conflict(
770 "Could not allocate an item number.".into(),
771 ));
772 }
773 }
774 }
775 };
776
777 let item_id = Uuid::new_v4();
778 sqlx::query(
779 "INSERT INTO items (id, number, item_type, unit, project_id, created_by, owner_id, classification)
780 VALUES ($1, $2, $3, $4, $5, $6, $6, $7)",
781 )
782 .bind(item_id)
783 .bind(&number)
784 .bind(input.item_type.as_str())
785 .bind(&unit)
786 .bind(project_id)
787 .bind(actor.id)
788 .bind(input.classification.as_str())
789 .execute(&mut *tx)
790 .await
791 .map_err(|e| {
792 AppError::unique_violation(e, format!("The item number '{number}' is already in use."))
793 })?;
794
795 let rev_id = Uuid::new_v4();
796 sqlx::query(
797 "INSERT INTO revisions (id, item_id, seq, code, state, name, description, material,
798 mass_kg, unit_cost, created_by, checked_out_by, checked_out_at,
799 attributes)
800 VALUES ($1, $2, 1, $3, 'in_work', $4, $5, $6, $7, $8, $9, $9, now(), $10)",
801 )
802 .bind(rev_id)
803 .bind(item_id)
804 .bind(domain::revision_code(1))
805 .bind(&attrs.name)
806 .bind(&attrs.description)
807 .bind(&attrs.material)
808 .bind(attrs.mass_kg)
809 .bind(attrs.unit_cost)
810 .bind(actor.id)
811 .bind(&attribute_values)
812 .execute(&mut *tx)
813 .await?;
814
815 audit::record(
816 tx,
817 Some(actor),
818 "item.create",
819 "item",
820 Some(item_id),
821 format!(
822 "Created {} {number} – {}",
823 input.item_type.label(),
824 attrs.name
825 ),
826 json!({ "revision_id": rev_id, "item_type": input.item_type.as_str() }),
827 )
828 .await?;
829 notifications::add_watch(tx, actor.id, "item", item_id).await?;
831
832 Ok(Created {
833 item_id,
834 revision_id: rev_id,
835 number,
836 })
837}
838
839pub async fn update_master(
856 pool: &PgPool,
857 actor: &Actor,
858 item_id: Uuid,
859 project_id: Option<Uuid>,
860 unit: &str,
861 classification: Option<Classification>,
862) -> AppResult<Item> {
863 actor.require(Role::Editor)?;
864 let unit = validate_unit(unit)?;
865 let mut tx = pool.begin().await?;
866 let current =
867 sqlx::query_as::<_, Item>(&format!("{ITEM_SELECT} WHERE i.id = $1 FOR UPDATE OF i"))
868 .bind(item_id)
869 .fetch_optional(&mut *tx)
870 .await?
871 .ok_or_else(|| AppError::not_found("Item not found."))?;
872 access::ensure_visible(actor, current.project_id, access::not_found_item())?;
873 if !actor.can_edit_project(current.project_id) {
875 return Err(AppError::Forbidden(format!(
876 "Only members of the project group {} can change {}.",
877 current.project_code.as_deref().unwrap_or("?"),
878 current.number
879 )));
880 }
881 let Some(pid) = project_id else {
882 return Err(AppError::BadRequest("Choose a project group.".into()));
883 };
884 let exists =
885 sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM projects WHERE id = $1)")
886 .bind(pid)
887 .fetch_one(&mut *tx)
888 .await?;
889 if !exists {
890 return Err(access::unknown_project());
891 }
892 access::ensure_visible(actor, Some(pid), access::unknown_project())?;
893 if !actor.can_edit_project(Some(pid)) {
894 return Err(AppError::Forbidden(
895 "You can only move items into project groups you are a member of.".into(),
896 ));
897 }
898 if current.project_id != Some(pid) {
901 if let Some(source) = current.project_id {
902 ensure_may_take_out(&mut tx, actor, source, "Moving").await?;
903 }
904 }
905 if unit != current.unit {
906 let released = sqlx::query_scalar::<_, bool>(
907 "SELECT EXISTS(SELECT 1 FROM revisions WHERE item_id = $1 AND state IN ('released', 'obsolete', 'in_review'))",
908 )
909 .bind(item_id)
910 .fetch_one(&mut *tx)
911 .await?;
912 if released {
913 return Err(AppError::Conflict(
914 "The unit cannot be changed after a revision was submitted or released.".into(),
915 ));
916 }
917 }
918 let classification = classification
919 .map(Classification::as_str)
920 .unwrap_or(current.classification.as_str());
921 sqlx::query("UPDATE items SET project_id = $2, unit = $3, classification = $4 WHERE id = $1")
922 .bind(item_id)
923 .bind(project_id)
924 .bind(&unit)
925 .bind(classification)
926 .execute(&mut *tx)
927 .await?;
928 audit::record(
929 &mut tx,
930 Some(actor),
931 "item.update",
932 "item",
933 Some(item_id),
934 format!("Updated master data of {}", current.number),
935 json!({ "project_id": project_id, "unit": unit, "classification": classification }),
936 )
937 .await?;
938 tx.commit().await?;
939 get(pool, actor, item_id).await
940}
941
942pub(crate) async fn ensure_may_take_out(
947 conn: &mut PgConnection,
948 actor: &Actor,
949 source: Uuid,
950 what: &str,
951) -> AppResult<()> {
952 let restricted = sqlx::query_scalar::<_, bool>(
953 "SELECT EXISTS(SELECT 1 FROM projects WHERE id = $1 AND visibility = 'restricted')",
954 )
955 .bind(source)
956 .fetch_one(&mut *conn)
957 .await?;
958 if restricted && !crate::services::projects::can_manage(conn, actor, source).await? {
959 return Err(AppError::Forbidden(format!(
960 "{what} out of a restricted group requires a manager of that group."
961 )));
962 }
963 Ok(())
964}
965
966pub async fn delete(pool: &PgPool, actor: &Actor, item_id: Uuid) -> AppResult<String> {
989 actor.require(Role::Editor)?;
990 let mut tx = pool.begin().await?;
991 let item = sqlx::query_as::<_, Item>(&format!("{ITEM_SELECT} WHERE i.id = $1 FOR UPDATE OF i"))
992 .bind(item_id)
993 .fetch_optional(&mut *tx)
994 .await?
995 .ok_or_else(|| AppError::not_found("Item not found."))?;
996 access::ensure_visible(actor, item.project_id, access::not_found_item())?;
997 if (item.created_by != actor.id || !actor.can_edit_project(item.project_id))
998 && !actor.is_admin()
999 {
1000 return Err(AppError::Forbidden(
1001 "Only the creator (as member of the project group) or an administrator can delete an item.".into(),
1002 ));
1003 }
1004 let not_in_work = sqlx::query_scalar::<_, i64>(
1005 "SELECT COUNT(*) FROM revisions WHERE item_id = $1 AND (state <> 'in_work' OR seq > 1)",
1006 )
1007 .bind(item_id)
1008 .fetch_one(&mut *tx)
1009 .await?;
1010 if not_in_work > 0 {
1011 return Err(AppError::Conflict(
1012 "Items that were ever submitted, released or revised cannot be deleted; mark them obsolete instead.".into(),
1013 ));
1014 }
1015 let used = sqlx::query_scalar::<_, bool>(
1016 "SELECT EXISTS(SELECT 1 FROM bom_lines WHERE child_item_id = $1)",
1017 )
1018 .bind(item_id)
1019 .fetch_one(&mut *tx)
1020 .await?;
1021 if used {
1022 return Err(AppError::Conflict(
1023 "The item is used in a BOM; remove it from all BOMs first.".into(),
1024 ));
1025 }
1026 let in_change = sqlx::query_scalar::<_, bool>(
1027 "SELECT EXISTS(SELECT 1 FROM change_items ci JOIN revisions r ON r.id = ci.revision_id WHERE r.item_id = $1)",
1028 )
1029 .bind(item_id)
1030 .fetch_one(&mut *tx)
1031 .await?;
1032 if in_change {
1033 return Err(AppError::Conflict(
1034 "The item is part of a change; remove it from the change first.".into(),
1035 ));
1036 }
1037 if crate::services::simulations::item_is_basis(&mut tx, item_id).await? {
1038 return Err(AppError::Conflict(
1039 "A simulation is based on the item; remove it from the simulation's basis first."
1040 .into(),
1041 ));
1042 }
1043 if crate::services::as_built::item_is_design(&mut tx, item_id).await? {
1044 return Err(AppError::Conflict(
1045 "Units were recorded as built to the item; it stays for their traceability.".into(),
1046 ));
1047 }
1048 let restricted = match item.project_id {
1054 Some(p) => sqlx::query_scalar::<_, String>(
1055 "SELECT visibility FROM projects WHERE id = $1 FOR SHARE",
1056 )
1057 .bind(p)
1058 .fetch_optional(&mut *tx)
1059 .await?
1060 .is_some_and(|v| v == ProjectVisibility::Restricted.as_str()),
1061 None => false,
1062 };
1063 let in_request = restricted
1064 && sqlx::query_scalar::<_, bool>(
1065 "SELECT EXISTS(SELECT 1 FROM change_request_items WHERE item_id = $1)",
1066 )
1067 .bind(item_id)
1068 .fetch_one(&mut *tx)
1069 .await?;
1070 if in_request {
1071 return Err(AppError::Conflict(
1072 "The item is an affected item of a change request; remove it from the request first."
1073 .into(),
1074 ));
1075 }
1076 if !restricted {
1077 crate::services::requests::item_deleted(&mut tx, actor, item_id, &item.number).await?;
1080 }
1081 sqlx::query("DELETE FROM items WHERE id = $1")
1082 .bind(item_id)
1083 .execute(&mut *tx)
1084 .await?;
1085 audit::record(
1086 &mut tx,
1087 Some(actor),
1088 "item.delete",
1089 "item",
1090 Some(item_id),
1091 format!("Deleted item {}", item.number),
1092 json!({}),
1093 )
1094 .await?;
1095 tx.commit().await?;
1096 Ok(item.number)
1097}
1098
1099pub async fn where_used(
1103 pool: &PgPool,
1104 actor: &Actor,
1105 item_id: Uuid,
1106 limit: i64,
1107) -> AppResult<Vec<WhereUsed>> {
1108 Ok(sqlx::query_as::<_, WhereUsed>(&format!(
1109 "SELECT bl.id AS line_id, r.id AS parent_revision_id, i.id AS parent_item_id,
1110 i.number AS parent_number, r.code AS parent_code, r.name AS parent_name,
1111 r.state AS parent_state, bl.find_number, bl.quantity, rc.code AS released_child_code
1112 FROM bom_lines bl
1113 JOIN revisions r ON r.id = bl.parent_revision_id
1114 JOIN items i ON i.id = r.item_id
1115 LEFT JOIN revisions rc ON rc.id = bl.released_child_revision_id
1116 WHERE bl.child_item_id = $1 AND {}
1117 ORDER BY i.number, r.seq DESC
1118 LIMIT $3",
1119 access::item_visible("i", 2)
1120 ))
1121 .bind(item_id)
1122 .bind(&actor.hidden_projects)
1123 .bind(limit.max(1))
1124 .fetch_all(pool)
1125 .await?)
1126}
1127
1128pub async fn hidden_parents(pool: &PgPool, actor: &Actor, item_id: Uuid) -> AppResult<i64> {
1131 if actor.hidden_projects.is_empty() {
1132 return Ok(0);
1133 }
1134 Ok(sqlx::query_scalar::<_, i64>(&format!(
1135 "SELECT COUNT(DISTINCT i.id)
1136 FROM bom_lines bl
1137 JOIN revisions r ON r.id = bl.parent_revision_id
1138 JOIN items i ON i.id = r.item_id
1139 WHERE bl.child_item_id = $1 AND NOT {}",
1140 access::item_visible("i", 2)
1141 ))
1142 .bind(item_id)
1143 .bind(&actor.hidden_projects)
1144 .fetch_one(pool)
1145 .await?)
1146}
1147
1148pub const MAX_WHERE_USED_LEVELS: i32 = 25;
1150pub const MAX_WHERE_USED_ROWS: i64 = 2000;
1152
1153pub async fn where_used_levels(
1161 pool: &PgPool,
1162 actor: &Actor,
1163 item_id: Uuid,
1164 levels: i32,
1165) -> AppResult<Vec<WhereUsedLevel>> {
1166 let levels = levels.clamp(1, MAX_WHERE_USED_LEVELS);
1167 let mut out: Vec<WhereUsedLevel> = Vec::new();
1168 let mut seen = std::collections::HashSet::from([item_id]);
1169 let mut frontier = vec![item_id];
1170 let sql = format!(
1173 "SELECT $2::int AS level, ci.number AS child_number,
1174 bl.id AS line_id, r.id AS parent_revision_id, i.id AS parent_item_id,
1175 i.number AS parent_number, r.code AS parent_code, r.name AS parent_name,
1176 r.state AS parent_state, bl.find_number, bl.quantity, rc.code AS released_child_code
1177 FROM bom_lines bl
1178 JOIN items ci ON ci.id = bl.child_item_id
1179 JOIN revisions r ON r.id = bl.parent_revision_id
1180 JOIN items i ON i.id = r.item_id
1181 LEFT JOIN revisions rc ON rc.id = bl.released_child_revision_id
1182 WHERE bl.child_item_id = ANY($1) AND {}
1183 ORDER BY i.number, r.seq DESC, ci.number
1184 LIMIT $3",
1185 access::item_visible("i", 4)
1186 );
1187 for level in 1..=levels {
1188 let remaining = MAX_WHERE_USED_ROWS - out.len() as i64;
1189 if frontier.is_empty() || remaining <= 0 {
1190 break;
1191 }
1192 let rows = sqlx::query_as::<_, WhereUsedLevel>(&sql)
1193 .bind(&frontier)
1194 .bind(level)
1195 .bind(remaining)
1196 .bind(&actor.hidden_projects)
1197 .fetch_all(pool)
1198 .await?;
1199 frontier = rows
1202 .iter()
1203 .map(|r| r.usage.parent_item_id)
1204 .filter(|id| seen.insert(*id))
1205 .collect();
1206 out.extend(rows);
1207 }
1208 Ok(out)
1209}