Skip to main content

teamcraft/domain/
items.rs

1//! Item types, classification units and the numbering schemes.
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::AppError;
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum ItemType {
10    Part,
11    Assembly,
12    Document,
13    Software,
14    /// An analysis (FEA, CFD, …): model, solver deck, results and report,
15    /// linked to the revisions it is based on (see `services::simulations`).
16    Simulation,
17    /// One physical unit (or lot) as it was really built: the design revision
18    /// it was built to, serial number, as-built BOM and deviations from the
19    /// design (see `services::as_built`).
20    AsBuilt,
21}
22
23impl ItemType {
24    pub const ALL: [ItemType; 6] = [
25        ItemType::Part,
26        ItemType::Assembly,
27        ItemType::Document,
28        ItemType::Software,
29        ItemType::Simulation,
30        ItemType::AsBuilt,
31    ];
32
33    pub fn as_str(self) -> &'static str {
34        match self {
35            ItemType::Part => "part",
36            ItemType::Assembly => "assembly",
37            ItemType::Document => "document",
38            ItemType::Software => "software",
39            ItemType::Simulation => "simulation",
40            ItemType::AsBuilt => "as_built",
41        }
42    }
43
44    pub fn label(self) -> &'static str {
45        match self {
46            ItemType::Part => "Part",
47            ItemType::Assembly => "Assembly",
48            ItemType::Document => "Document",
49            ItemType::Software => "Software",
50            ItemType::Simulation => "Simulation",
51            ItemType::AsBuilt => "As-built",
52        }
53    }
54
55    /// Prefix used for automatically generated item numbers.
56    pub fn number_prefix(self) -> &'static str {
57        match self {
58            ItemType::Part => "PRT",
59            ItemType::Assembly => "ASM",
60            ItemType::Document => "DOC",
61            ItemType::Software => "SW",
62            ItemType::Simulation => "SIM",
63            ItemType::AsBuilt => "AB",
64        }
65    }
66
67    /// Also accepts `as-built` and `as built`.
68    pub fn parse(s: &str) -> Option<ItemType> {
69        match s
70            .trim()
71            .to_ascii_lowercase()
72            .replace(['-', ' '], "_")
73            .as_str()
74        {
75            "part" => Some(ItemType::Part),
76            "assembly" => Some(ItemType::Assembly),
77            "document" => Some(ItemType::Document),
78            "software" => Some(ItemType::Software),
79            "simulation" => Some(ItemType::Simulation),
80            "as_built" => Some(ItemType::AsBuilt),
81            _ => None,
82        }
83    }
84}
85
86/// Numbering scheme used when a project group has none: the type prefix and a
87/// six-digit sequence, e.g. `PRT-000042`.
88pub const DEFAULT_NUMBER_SCHEME: &str = "{TYPE}-{SEQ:6}";
89
90/// Widest sequence a scheme may ask for (`{SEQ:12}`).
91const MAX_SEQ_WIDTH: usize = 12;
92
93/// A numbering scheme for generated item numbers, e.g.
94/// `{PROJECT}-{TYPE}-{YYYY}-{SEQ:4}` → `ROVER-PRT-2026-0007`.
95///
96/// Placeholders: `{PROJECT}` (project code), `{TYPE}` (PRT, ASM, DOC, SW, SIM, AB),
97/// `{YYYY}`, `{YY}`, `{MM}` (creation date, UTC) and exactly one `{SEQ:n}`
98/// (sequence, zero-padded to `n` digits; `{SEQ}` pads nothing). Everything
99/// else is literal text from the item-number alphabet `A–Z 0–9 . _ -`.
100///
101/// Each distinct expansion of the scheme without the sequence counts on its
102/// own: `{PROJECT}-{SEQ:4}` counts per project group, a scheme with `{YYYY}`
103/// starts at 1 every year, a scheme without `{TYPE}` shares one sequence
104/// between all item types.
105#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct NumberScheme {
107    parts: Vec<SchemePart>,
108}
109
110#[derive(Clone, Debug, PartialEq, Eq)]
111enum SchemePart {
112    Text(String),
113    Project,
114    Type,
115    Year4,
116    Year2,
117    Month,
118    Seq(usize),
119}
120
121/// The values a scheme is expanded with.
122#[derive(Clone, Copy, Debug)]
123pub struct NumberContext<'a> {
124    pub project: &'a str,
125    pub item_type: ItemType,
126    pub year: i32,
127    pub month: u32,
128}
129
130/// The values for an item of `item_type` in project group `project` created at `at`.
131pub fn number_context(
132    project: &str,
133    item_type: ItemType,
134    at: chrono::DateTime<chrono::Utc>,
135) -> NumberContext<'_> {
136    use chrono::Datelike;
137    NumberContext {
138        project,
139        item_type,
140        year: at.year(),
141        month: at.month(),
142    }
143}
144
145impl NumberScheme {
146    /// Parses and validates a scheme; placeholders are case-insensitive and
147    /// the literal text is upper-cased like item numbers.
148    pub fn parse(raw: &str) -> Result<NumberScheme, AppError> {
149        let s = raw.trim().to_ascii_uppercase();
150        let bad = |why: &str| {
151            AppError::BadRequest(format!("Invalid numbering scheme '{}': {why}", raw.trim()))
152        };
153        if s.is_empty() {
154            return Err(bad("it is empty."));
155        }
156        if s.len() > 100 {
157            return Err(bad("use at most 100 characters."));
158        }
159        let mut parts = Vec::new();
160        let mut text = String::new();
161        let mut rest = s.as_str();
162        while let Some(c) = rest.chars().next() {
163            if c == '{' {
164                let end = rest
165                    .find('}')
166                    .ok_or_else(|| bad("a '{' has no closing '}'."))?;
167                let token = &rest[1..end];
168                let part = match token {
169                    "PROJECT" => SchemePart::Project,
170                    "TYPE" => SchemePart::Type,
171                    "YYYY" => SchemePart::Year4,
172                    "YY" => SchemePart::Year2,
173                    "MM" => SchemePart::Month,
174                    "SEQ" => SchemePart::Seq(1),
175                    _ => match token.strip_prefix("SEQ:").map(str::parse::<usize>) {
176                        Some(Ok(n)) if (1..=MAX_SEQ_WIDTH).contains(&n) => SchemePart::Seq(n),
177                        Some(_) => {
178                            return Err(bad(&format!(
179                                "the sequence width in {{{token}}} must be 1 to {MAX_SEQ_WIDTH}."
180                            )))
181                        }
182                        None => {
183                            return Err(bad(&format!(
184                                "unknown placeholder {{{token}}}; use {{PROJECT}}, {{TYPE}}, {{YYYY}}, {{YY}}, {{MM}} or {{SEQ:n}}."
185                            )))
186                        }
187                    },
188                };
189                if !text.is_empty() {
190                    parts.push(SchemePart::Text(std::mem::take(&mut text)));
191                }
192                parts.push(part);
193                rest = &rest[end + 1..];
194            } else if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
195                text.push(c);
196                rest = &rest[1..];
197            } else {
198                return Err(bad(&format!(
199                    "'{c}' is not allowed; use A–Z, 0–9, '.', '_', '-' and placeholders in braces."
200                )));
201            }
202        }
203        if !text.is_empty() {
204            parts.push(SchemePart::Text(text));
205        }
206        match parts
207            .iter()
208            .filter(|p| matches!(p, SchemePart::Seq(_)))
209            .count()
210        {
211            0 => return Err(bad("it needs a sequence placeholder such as {SEQ:4}.")),
212            1 => {}
213            _ => return Err(bad("use the sequence placeholder only once.")),
214        }
215        if let Some(SchemePart::Text(t)) = parts.first() {
216            if !t.starts_with(|c: char| c.is_ascii_alphanumeric()) {
217                return Err(bad("item numbers start with a letter or digit."));
218            }
219        }
220        let scheme = NumberScheme { parts };
221        if scheme.max_len() > 64 {
222            return Err(bad(&format!(
223                "numbers could get {} characters long, item numbers have at most 64 (a project code has up to 32).",
224                scheme.max_len()
225            )));
226        }
227        Ok(scheme)
228    }
229
230    /// The built-in scheme `{TYPE}-{SEQ:6}`.
231    pub fn default_scheme() -> NumberScheme {
232        NumberScheme::parse(DEFAULT_NUMBER_SCHEME).expect("valid default scheme")
233    }
234
235    /// The stored scheme of a project group, or the default.
236    pub fn of_project(stored: Option<&str>) -> Result<NumberScheme, AppError> {
237        match stored.map(str::trim).filter(|s| !s.is_empty()) {
238            Some(s) => NumberScheme::parse(s),
239            None => Ok(NumberScheme::default_scheme()),
240        }
241    }
242
243    /// Longest possible number (project codes have up to 32 characters, the
244    /// sequence is counted with its padding width).
245    fn max_len(&self) -> usize {
246        self.parts
247            .iter()
248            .map(|p| match p {
249                SchemePart::Text(t) => t.len(),
250                SchemePart::Project => 32,
251                SchemePart::Type => 3,
252                SchemePart::Year4 => 4,
253                SchemePart::Year2 | SchemePart::Month => 2,
254                SchemePart::Seq(n) => *n,
255            })
256            .sum()
257    }
258
259    /// The scheme in its canonical spelling (upper case, `{SEQ:n}`).
260    pub fn as_string(&self) -> String {
261        self.expand_with(|p| match p {
262            SchemePart::Text(t) => t.clone(),
263            SchemePart::Project => "{PROJECT}".into(),
264            SchemePart::Type => "{TYPE}".into(),
265            SchemePart::Year4 => "{YYYY}".into(),
266            SchemePart::Year2 => "{YY}".into(),
267            SchemePart::Month => "{MM}".into(),
268            SchemePart::Seq(1) => "{SEQ}".into(),
269            SchemePart::Seq(n) => format!("{{SEQ:{n}}}"),
270        })
271    }
272
273    fn expand_with(&self, f: impl Fn(&SchemePart) -> String) -> String {
274        self.parts.iter().map(f).collect()
275    }
276
277    fn fixed(part: &SchemePart, ctx: &NumberContext<'_>) -> String {
278        match part {
279            SchemePart::Text(t) => t.clone(),
280            SchemePart::Project => ctx.project.to_string(),
281            SchemePart::Type => ctx.item_type.number_prefix().to_string(),
282            SchemePart::Year4 => format!("{:04}", ctx.year),
283            SchemePart::Year2 => format!("{:02}", ctx.year.rem_euclid(100)),
284            SchemePart::Month => format!("{:02}", ctx.month),
285            SchemePart::Seq(_) => "{SEQ}".into(),
286        }
287    }
288
289    /// Name of the counter that numbers this expansion: everything but the
290    /// sequence filled in, e.g. `num:ROVER-PRT-2026-{SEQ}`. The padding width
291    /// is not part of it, so widening `{SEQ:4}` to `{SEQ:6}` keeps counting.
292    pub fn counter_key(&self, ctx: &NumberContext<'_>) -> String {
293        format!("num:{}", self.expand_with(|p| Self::fixed(p, ctx)))
294    }
295
296    /// The item number for sequence value `seq`.
297    pub fn format(&self, ctx: &NumberContext<'_>, seq: i64) -> String {
298        self.expand_with(|p| match p {
299            SchemePart::Seq(n) => format!("{seq:0n$}", n = *n),
300            other => Self::fixed(other, ctx),
301        })
302    }
303
304    /// The expansion before and after the sequence, e.g. `ROVER-PRT-2026-`
305    /// and `` for `{PROJECT}-{TYPE}-{YYYY}-{SEQ:4}`.
306    pub fn around_seq(&self, ctx: &NumberContext<'_>) -> (String, String) {
307        let at = self
308            .parts
309            .iter()
310            .position(|p| matches!(p, SchemePart::Seq(_)))
311            .unwrap_or(self.parts.len());
312        let text = |parts: &[SchemePart]| -> String {
313            parts.iter().map(|p| Self::fixed(p, ctx)).collect()
314        };
315        (
316            text(&self.parts[..at]),
317            text(self.parts.get(at + 1..).unwrap_or_default()),
318        )
319    }
320
321    /// The sequence value of `number` if this expansion can produce it
322    /// (any width), e.g. 12 for `ROVER-PRT-2026-0012` or `ROVER-PRT-2026-12`.
323    pub fn seq_of(&self, ctx: &NumberContext<'_>, number: &str) -> Option<i64> {
324        let (prefix, suffix) = self.around_seq(ctx);
325        let digits = number.strip_prefix(&prefix)?.strip_suffix(&suffix)?;
326        if digits.is_empty() || digits.len() > 18 || !digits.bytes().all(|b| b.is_ascii_digit()) {
327            return None;
328        }
329        digits.parse().ok()
330    }
331}
332
333/// Formats an automatically generated change number, e.g. `CHG-000007`.
334pub fn format_change_number(value: i64) -> String {
335    format!("CHG-{value:06}")
336}
337
338/// Common units of measure offered in the UI. Free text is accepted as well.
339pub const COMMON_UNITS: [&str; 9] = ["each", "set", "kg", "g", "m", "mm", "m2", "l", "ml"];