1use serde::{Deserialize, Serialize};
4
5use super::*;
6use crate::error::AppError;
7
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum Classification {
12 #[default]
14 Make,
15 Purchased,
17 Standard,
19}
20
21impl Classification {
22 pub const ALL: [Classification; 3] = [
23 Classification::Make,
24 Classification::Purchased,
25 Classification::Standard,
26 ];
27
28 pub fn as_str(self) -> &'static str {
29 match self {
30 Classification::Make => "make",
31 Classification::Purchased => "purchased",
32 Classification::Standard => "standard",
33 }
34 }
35
36 pub fn label(self) -> &'static str {
37 match self {
38 Classification::Make => "In-house",
39 Classification::Purchased => "Purchased",
40 Classification::Standard => "Standard part",
41 }
42 }
43
44 pub fn parse(s: &str) -> Option<Classification> {
45 match s.trim().to_ascii_lowercase().as_str() {
46 "make" | "in-house" | "inhouse" | "normal" => Some(Classification::Make),
47 "purchased" | "buy" | "bought" => Some(Classification::Purchased),
48 "standard" | "standard part" | "library" => Some(Classification::Standard),
49 _ => None,
50 }
51 }
52
53 pub fn from_bom_structure(raw: &str) -> Option<Classification> {
58 let s: String = raw
59 .chars()
60 .filter(|c| c.is_alphanumeric())
61 .flat_map(char::to_lowercase)
62 .collect();
63 match s.as_str() {
64 "normal" | "make" | "inhouse" | "eigenfertigung" | "eigenteil" | "fertigungsteil"
65 | "fertigung" | "phantom" | "inseparable" | "untrennbar" => Some(Classification::Make),
66 "purchased" | "purchase" | "buy" | "bought" | "kaufteil" | "zukaufteil" | "zukauf"
67 | "gekauft" | "einkauf" | "einkaufsteil" => Some(Classification::Purchased),
68 "standard" | "standardpart" | "normteil" | "norm" | "normalien" | "library"
69 | "librarypart" | "contentcenter" | "toolbox" | "katalogteil" => {
70 Some(Classification::Standard)
71 }
72 _ => None,
73 }
74 }
75
76 pub fn parse_field(raw: Option<&str>) -> Result<Option<Classification>, AppError> {
78 match raw.map(str::trim).filter(|s| !s.is_empty()) {
79 None => Ok(None),
80 Some(s) => Classification::parse(s).map(Some).ok_or_else(|| {
81 AppError::BadRequest(format!(
82 "Unknown classification '{s}': use make, purchased or standard."
83 ))
84 }),
85 }
86 }
87}
88
89#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum AttributeType {
93 Text,
94 Number,
95 Boolean,
96 Choice,
98}
99
100impl AttributeType {
101 pub const ALL: [AttributeType; 4] = [
102 AttributeType::Text,
103 AttributeType::Number,
104 AttributeType::Boolean,
105 AttributeType::Choice,
106 ];
107
108 pub fn as_str(self) -> &'static str {
109 match self {
110 AttributeType::Text => "text",
111 AttributeType::Number => "number",
112 AttributeType::Boolean => "boolean",
113 AttributeType::Choice => "choice",
114 }
115 }
116
117 pub fn label(self) -> &'static str {
118 match self {
119 AttributeType::Text => "Text",
120 AttributeType::Number => "Number",
121 AttributeType::Boolean => "Yes/no",
122 AttributeType::Choice => "Choice list",
123 }
124 }
125
126 pub fn parse(s: &str) -> Option<AttributeType> {
127 match s.trim().to_ascii_lowercase().as_str() {
128 "text" => Some(AttributeType::Text),
129 "number" => Some(AttributeType::Number),
130 "boolean" | "bool" | "yes/no" => Some(AttributeType::Boolean),
131 "choice" | "list" => Some(AttributeType::Choice),
132 _ => None,
133 }
134 }
135}
136
137pub const MAX_ATTRIBUTE_TEXT: usize = 500;
139
140pub fn normalize_attribute_key(raw: &str) -> Result<String, AppError> {
144 let mut key = String::new();
145 for c in raw.trim().chars() {
146 match c {
147 'a'..='z' | '0'..='9' => key.push(c),
148 'A'..='Z' => key.push(c.to_ascii_lowercase()),
149 ' ' | '-' | '_' | '.' | '/' if !key.is_empty() && !key.ends_with('_') => key.push('_'),
150 ' ' | '-' | '_' | '.' | '/' => {}
151 _ => {
152 return Err(AppError::BadRequest(format!(
153 "Invalid attribute key '{raw}': use letters, digits and '_'."
154 )))
155 }
156 }
157 }
158 let key = key.trim_end_matches('_').to_string();
159 if key.is_empty() || key.len() > 40 || !key.starts_with(|c: char| c.is_ascii_lowercase()) {
160 return Err(AppError::BadRequest(format!(
161 "Invalid attribute key '{raw}': 1–40 characters a–z, 0–9 and '_', starting with a letter."
162 )));
163 }
164 Ok(key)
165}
166
167pub fn parse_attribute_choices(raw: &str) -> Result<Vec<String>, AppError> {
170 let mut out: Vec<String> = Vec::new();
171 for c in raw
172 .split(['\n', ';'])
173 .map(str::trim)
174 .filter(|c| !c.is_empty())
175 {
176 if c.chars().count() > 100 {
177 return Err(AppError::BadRequest(format!(
178 "Choice '{c}' is longer than 100 characters."
179 )));
180 }
181 if !out.iter().any(|o| o.eq_ignore_ascii_case(c)) {
182 out.push(c.to_string());
183 }
184 }
185 if out.len() > 200 {
186 return Err(AppError::BadRequest("At most 200 choices.".into()));
187 }
188 Ok(out)
189}
190
191fn parse_yes_no(raw: &str) -> Option<bool> {
192 match raw.trim().to_ascii_lowercase().as_str() {
193 "yes" | "y" | "true" | "1" | "on" | "ja" | "x" => Some(true),
194 "no" | "n" | "false" | "0" | "off" | "nein" => Some(false),
195 _ => None,
196 }
197}
198
199pub fn parse_attribute_value(
202 label: &str,
203 data_type: AttributeType,
204 choices: &[String],
205 raw: &str,
206) -> Result<Option<serde_json::Value>, AppError> {
207 let raw = raw.trim();
208 if raw.is_empty() {
209 return Ok(None);
210 }
211 let value = match data_type {
212 AttributeType::Text => {
213 serde_json::Value::String(limit_text(label, raw, MAX_ATTRIBUTE_TEXT)?)
214 }
215 AttributeType::Number => {
216 let n = parse_decimal(label, Some(raw))?.unwrap_or_default();
217 if !n.is_finite() {
218 return Err(AppError::BadRequest(format!(
219 "{label}: '{raw}' is not a number."
220 )));
221 }
222 serde_json::Number::from_f64(n)
223 .map(serde_json::Value::Number)
224 .ok_or_else(|| AppError::BadRequest(format!("{label}: '{raw}' is not a number.")))?
225 }
226 AttributeType::Boolean => {
227 serde_json::Value::Bool(parse_yes_no(raw).ok_or_else(|| {
228 AppError::BadRequest(format!("{label}: '{raw}' is not yes or no."))
229 })?)
230 }
231 AttributeType::Choice => {
232 let choice = choices
233 .iter()
234 .find(|c| c.eq_ignore_ascii_case(raw))
235 .ok_or_else(|| {
236 AppError::BadRequest(format!(
237 "{label}: '{raw}' is not one of {}.",
238 choices.join(", ")
239 ))
240 })?;
241 serde_json::Value::String(choice.clone())
242 }
243 };
244 Ok(Some(value))
245}
246
247pub fn attribute_input_text(value: &serde_json::Value) -> Result<String, AppError> {
249 match value {
250 serde_json::Value::Null => Ok(String::new()),
251 serde_json::Value::String(s) => Ok(s.clone()),
252 serde_json::Value::Number(n) => Ok(n.to_string()),
253 serde_json::Value::Bool(b) => Ok(b.to_string()),
254 _ => Err(AppError::BadRequest(
255 "Attribute values are text, numbers, true/false or null.".into(),
256 )),
257 }
258}
259
260#[derive(Clone, Debug, PartialEq)]
262pub enum AttributeCondition {
263 Present,
265 Contains(String),
267 Bool(bool),
268 Range(Option<f64>, Option<f64>),
270}
271
272pub fn parse_attribute_condition(
277 label: &str,
278 data_type: AttributeType,
279 raw: &str,
280) -> Result<AttributeCondition, AppError> {
281 let raw = raw.trim();
282 if raw.is_empty() {
283 return Ok(AttributeCondition::Present);
284 }
285 let num = |s: &str| -> Result<Option<f64>, AppError> {
286 parse_decimal(label, Some(s)).and_then(|n| match n {
287 Some(v) if !v.is_finite() => Err(AppError::BadRequest(format!(
288 "{label}: '{s}' is not a number."
289 ))),
290 other => Ok(other),
291 })
292 };
293 Ok(match data_type {
294 AttributeType::Text | AttributeType::Choice => AttributeCondition::Contains(raw.into()),
295 AttributeType::Boolean => AttributeCondition::Bool(
296 parse_yes_no(raw)
297 .ok_or_else(|| AppError::BadRequest(format!("{label}: search for yes or no.")))?,
298 ),
299 AttributeType::Number => {
300 if let Some((lo, hi)) = raw.split_once("..") {
301 AttributeCondition::Range(num(lo)?, num(hi)?)
302 } else if let Some(v) = raw.strip_prefix(">=") {
303 AttributeCondition::Range(num(v)?, None)
304 } else if let Some(v) = raw.strip_prefix("<=") {
305 AttributeCondition::Range(None, num(v)?)
306 } else {
307 let v = num(raw)?;
308 AttributeCondition::Range(v, v)
309 }
310 }
311 })
312}