1use 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 Simulation,
17 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 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 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
86pub const DEFAULT_NUMBER_SCHEME: &str = "{TYPE}-{SEQ:6}";
89
90const MAX_SEQ_WIDTH: usize = 12;
92
93#[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#[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
130pub 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 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 pub fn default_scheme() -> NumberScheme {
232 NumberScheme::parse(DEFAULT_NUMBER_SCHEME).expect("valid default scheme")
233 }
234
235 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 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 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 pub fn counter_key(&self, ctx: &NumberContext<'_>) -> String {
293 format!("num:{}", self.expand_with(|p| Self::fixed(p, ctx)))
294 }
295
296 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 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 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
333pub fn format_change_number(value: i64) -> String {
335 format!("CHG-{value:06}")
336}
337
338pub const COMMON_UNITS: [&str; 9] = ["each", "set", "kg", "g", "m", "mm", "m2", "l", "ml"];