teamcraft/services/
duplicates.rs1use serde::Serialize;
11use sqlx::PgPool;
12use uuid::Uuid;
13
14use crate::{auth::Actor, domain::ItemType, error::AppResult, services::access};
15
16const CANDIDATES: i64 = 200;
18pub const THRESHOLD: f64 = 0.6;
20
21#[derive(Clone, Debug, Serialize, PartialEq)]
22pub struct Duplicate {
23 pub item_id: Uuid,
24 pub number: String,
25 pub item_type: String,
26 pub project_code: Option<String>,
27 pub revision_id: Uuid,
28 pub code: String,
29 pub state: String,
30 pub name: String,
31 pub score: f64,
33}
34
35#[derive(sqlx::FromRow)]
36struct Candidate {
37 item_id: Uuid,
38 number: String,
39 item_type: String,
40 project_code: Option<String>,
41 revision_id: Uuid,
42 code: String,
43 state: String,
44 name: String,
45}
46
47pub fn words(name: &str) -> Vec<String> {
50 let mut out: Vec<String> = Vec::new();
51 for w in name
52 .split(|c: char| !c.is_alphanumeric())
53 .map(str::to_lowercase)
54 .filter(|w| w.chars().count() >= 2)
55 {
56 if !out.contains(&w) {
57 out.push(w);
58 }
59 }
60 out
61}
62
63fn edit_distance(a: &[char], b: &[char]) -> usize {
65 let mut prev: Vec<usize> = (0..=b.len()).collect();
66 let mut cur = vec![0; b.len() + 1];
67 for (i, ca) in a.iter().enumerate() {
68 cur[0] = i + 1;
69 for (j, cb) in b.iter().enumerate() {
70 let cost = usize::from(ca != cb);
71 cur[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1);
72 }
73 std::mem::swap(&mut prev, &mut cur);
74 }
75 prev[b.len()]
76}
77
78pub fn similarity(a: &str, b: &str) -> f64 {
83 let (wa, wb) = (words(a), words(b));
84 let common = wa.iter().filter(|w| wb.contains(w)).count();
86 let union = wa.len() + wb.len() - common;
87 let jaccard = if union == 0 {
88 0.0
89 } else {
90 common as f64 / union as f64
91 };
92 let compact = |s: &str| -> Vec<char> { words(s).concat().chars().collect() };
93 let (ca, cb) = (compact(a), compact(b));
94 let longest = ca.len().max(cb.len());
95 let edit = if longest == 0 {
96 0.0
97 } else {
98 1.0 - edit_distance(&ca, &cb) as f64 / longest as f64
99 };
100 jaccard.max(edit)
101}
102
103pub async fn likely_duplicates(
107 pool: &PgPool,
108 actor: &Actor,
109 name: &str,
110 item_type: Option<ItemType>,
111 exclude: Option<Uuid>,
112 limit: usize,
113) -> AppResult<Vec<Duplicate>> {
114 let patterns: Vec<String> = words(name)
115 .into_iter()
116 .filter(|w| w.chars().count() >= 3)
117 .take(8)
118 .map(|w| crate::domain::like_pattern(&w))
119 .collect();
120 if patterns.is_empty() {
121 return Ok(Vec::new());
122 }
123 let sql = format!(
124 "SELECT i.id AS item_id, i.number, i.item_type, p.code AS project_code,
125 lr.id AS revision_id, lr.code, lr.state, lr.name
126 FROM items i
127 LEFT JOIN projects p ON p.id = i.project_id
128 CROSS JOIN LATERAL (
129 SELECT r.id, r.code, r.state, r.name FROM revisions r
130 WHERE r.item_id = i.id ORDER BY r.seq DESC LIMIT 1
131 ) lr
132 WHERE i.id IN (SELECT r.item_id FROM revisions r WHERE r.name ILIKE ANY($1))
133 AND lr.name ILIKE ANY($1)
134 AND ($2::text IS NULL OR i.item_type = $2)
135 AND ($3::uuid IS NULL OR i.id <> $3)
136 AND {}
137 ORDER BY i.number
138 LIMIT {CANDIDATES}",
139 access::item_visible("i", 4)
140 );
141 let rows = sqlx::query_as::<_, Candidate>(&sql)
142 .bind(&patterns)
143 .bind(item_type.map(|t| t.as_str()))
144 .bind(exclude)
145 .bind(&actor.hidden_projects)
146 .fetch_all(pool)
147 .await?;
148 let mut found: Vec<Duplicate> = rows
149 .into_iter()
150 .filter_map(|c| {
151 let score = similarity(name, &c.name);
152 (score >= THRESHOLD).then(|| Duplicate {
153 item_id: c.item_id,
154 number: c.number,
155 item_type: c.item_type,
156 project_code: c.project_code,
157 revision_id: c.revision_id,
158 code: c.code,
159 state: c.state,
160 name: c.name,
161 score: (score * 100.0).round() / 100.0,
162 })
163 })
164 .collect();
165 found.sort_by(|a, b| {
166 b.score
167 .partial_cmp(&a.score)
168 .unwrap_or(std::cmp::Ordering::Equal)
169 .then_with(|| a.number.cmp(&b.number))
170 });
171 found.truncate(limit);
172 Ok(found)
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn words_are_lower_case_and_unique() {
181 assert_eq!(
182 words("Bracket, M8 – left bracket"),
183 ["bracket", "m8", "left"]
184 );
185 assert!(words("a - b").is_empty());
186 assert_eq!(words("Lagerbock Ø20"), ["lagerbock", "ø20"]);
187 }
188
189 #[test]
190 fn similar_names() {
191 assert_eq!(similarity("Bracket left", "bracket LEFT"), 1.0);
192 assert_eq!(similarity("M8 bracket left", "Bracket left M8"), 1.0);
193 assert!(similarity("Bracket", "Brakcet") >= THRESHOLD, "typo");
194 assert!(similarity("Motor mount plate", "Motor mount plate v2") >= THRESHOLD);
195 assert!(similarity("Bracket", "Gearbox housing") < THRESHOLD);
196 assert!(similarity("Shaft", "Shaft collar clamp large") < THRESHOLD);
197 assert_eq!(similarity("", ""), 0.0);
198 assert_eq!(edit_distance(&['a', 'b'], &[]), 2);
199 }
200}