1use quick_xml::Reader;
2use quick_xml::events::Event;
3
4use crate::error::YukiError;
5
6use super::local_name;
7use super::soap_client::{SoapClient, SoapEnvelope};
8
9const BASE_URL: &str = "https://api.yukiworks.nl/ws/Archive.asmx";
10
11#[derive(Debug, Clone)]
13pub struct CostCategory {
14 pub id: String,
15 pub description: String,
16}
17
18#[derive(Debug, Clone)]
20pub struct PaymentMethod {
21 pub id: String,
22 pub description: String,
23}
24
25#[derive(Debug, Clone)]
27pub struct ArchiveDocument {
28 pub id: String,
29 pub subject: String,
30 pub document_date: String,
31 pub amount: String,
32 pub folder: String,
33 pub contact_name: String,
34 pub file_name: String,
35 pub reference: String,
36}
37
38pub struct ArchiveClient {
40 soap: SoapClient,
41}
42
43impl ArchiveClient {
44 pub fn new() -> Self {
45 Self {
46 soap: SoapClient::new(BASE_URL),
47 }
48 }
49
50 pub fn with_client(http: reqwest::Client) -> Self {
53 Self {
54 soap: SoapClient::with_client(BASE_URL, http),
55 }
56 }
57
58 fn require_session(&self) -> Result<&str, YukiError> {
59 self.soap.session_id().ok_or_else(|| {
60 YukiError::AuthFailed("not authenticated — call authenticate() first".to_string())
61 })
62 }
63
64 pub async fn authenticate(&mut self, api_key: &str) -> Result<String, YukiError> {
66 self.soap.authenticate(api_key).await
67 }
68
69 pub async fn documents_in_folder(
71 &self,
72 folder_id: i32,
73 start_date: &str,
74 end_date: &str,
75 ) -> Result<Vec<ArchiveDocument>, YukiError> {
76 let session = self.require_session()?;
77 let envelope = SoapEnvelope::new("DocumentsInFolder")
78 .session(session)
79 .param("folderID", &folder_id.to_string())
80 .param("sortOrder", "DocumentDateDesc")
81 .param("startDate", start_date)
82 .param("endDate", end_date)
83 .param("numberOfRecords", "100")
84 .param("startRecord", "0")
85 .build();
86 let body = self.soap.call("DocumentsInFolder", envelope).await?;
87 Self::parse_archive_documents(&body)
88 }
89
90 pub async fn documents_by_type(&self, doc_type: i32) -> Result<String, YukiError> {
92 let session = self.require_session()?;
93 let envelope = SoapEnvelope::new("DocumentsByType")
94 .session(session)
95 .param("documentType", &doc_type.to_string())
96 .build();
97 self.soap.call("DocumentsByType", envelope).await
98 }
99
100 pub async fn search_documents(
105 &self,
106 search_text: &str,
107 start_date: &str,
108 end_date: &str,
109 ) -> Result<Vec<ArchiveDocument>, YukiError> {
110 let session = self.require_session()?;
111 let envelope = SoapEnvelope::new("SearchDocuments")
112 .session(session)
113 .param("searchOption", "All")
114 .param("searchText", search_text)
115 .param("folderID", "-1")
116 .param("tabID", "-1")
117 .param("sortOrder", "DocumentDateDesc")
118 .param("startDate", start_date)
119 .param("endDate", end_date)
120 .param("numberOfRecords", "500")
121 .param("startRecord", "0")
122 .build();
123 let body = self.soap.call("SearchDocuments", envelope).await?;
124 Self::parse_archive_documents(&body)
125 }
126
127 pub async fn modified_documents_by_type(
129 &self,
130 doc_type: i32,
131 modified_since: &str,
132 ) -> Result<String, YukiError> {
133 let session = self.require_session()?;
134 let envelope = SoapEnvelope::new("ModifiedDocumentsByType")
135 .session(session)
136 .param("documentType", &doc_type.to_string())
137 .param("modifiedSince", modified_since)
138 .build();
139 self.soap.call("ModifiedDocumentsByType", envelope).await
140 }
141
142 pub async fn upload_document(
146 &self,
147 admin_id: &str,
148 filename: &str,
149 data_base64: &str,
150 folder_id: i32,
151 ) -> Result<String, YukiError> {
152 let session = self.require_session()?;
153 let envelope = SoapEnvelope::new("UploadDocument")
154 .session(session)
155 .param("fileName", filename)
156 .param("data", data_base64)
157 .param("folder", &folder_id.to_string())
158 .param("administrationID", admin_id)
159 .build();
160 let body = self.soap.call("UploadDocument", envelope).await?;
161 SoapClient::parse_single_result(&body, "UploadDocumentResult")
162 }
163
164 #[allow(clippy::too_many_arguments)]
168 pub async fn upload_document_with_data(
169 &self,
170 admin_id: &str,
171 filename: &str,
172 data_base64: &str,
173 folder_id: i32,
174 currency: &str,
175 amount: f64,
176 cost_category: Option<&str>,
177 payment_method: Option<&str>,
178 project: Option<&str>,
179 remarks: Option<&str>,
180 ) -> Result<String, YukiError> {
181 let session = self.require_session()?;
182 let amount_str = format!("{amount:.2}");
183 let envelope = SoapEnvelope::new("UploadDocumentWithData")
184 .session(session)
185 .param("fileName", filename)
186 .param("data", data_base64)
187 .param("folder", &folder_id.to_string())
188 .param("administrationID", admin_id)
189 .param("currency", currency)
190 .param("amount", &amount_str)
191 .param("costCategory", cost_category.unwrap_or(""))
192 .param("paymentMethod", payment_method.unwrap_or("0"))
193 .param("project", project.unwrap_or(""))
194 .param("remarks", remarks.unwrap_or(""))
195 .build();
196 let body = self.soap.call("UploadDocumentWithData", envelope).await?;
197 SoapClient::parse_single_result(&body, "UploadDocumentWithDataResult")
198 }
199
200 pub async fn cost_categories(&self) -> Result<Vec<CostCategory>, YukiError> {
202 let session = self.require_session()?;
203 let envelope = SoapEnvelope::new("CostCategories").session(session).build();
204 let body = self.soap.call("CostCategories", envelope).await?;
205 Self::parse_cost_categories(&body)
206 }
207
208 pub async fn payment_methods(&self) -> Result<Vec<PaymentMethod>, YukiError> {
210 let session = self.require_session()?;
211 let envelope = SoapEnvelope::new("PaymentMethods").session(session).build();
212 let body = self.soap.call("PaymentMethods", envelope).await?;
213 Self::parse_payment_methods(&body)
214 }
215
216 pub fn parse_archive_documents(xml: &str) -> Result<Vec<ArchiveDocument>, YukiError> {
221 let mut reader = Reader::from_str(xml);
222 reader.config_mut().trim_text(true);
223
224 let mut documents = Vec::new();
225 let mut in_document = false;
226 let mut current_field = String::new();
227 let mut doc = ArchiveDocument {
228 id: String::new(),
229 subject: String::new(),
230 document_date: String::new(),
231 amount: String::new(),
232 folder: String::new(),
233 contact_name: String::new(),
234 file_name: String::new(),
235 reference: String::new(),
236 };
237 let mut buf = Vec::new();
238
239 loop {
240 match reader.read_event_into(&mut buf) {
241 Ok(Event::Start(ref e)) => {
242 let local = local_name(e.name().as_ref()).to_string();
243 match local.as_str() {
244 "Document" => {
245 in_document = true;
246 doc = ArchiveDocument {
247 id: String::new(),
248 subject: String::new(),
249 document_date: String::new(),
250 amount: String::new(),
251 folder: String::new(),
252 contact_name: String::new(),
253 file_name: String::new(),
254 reference: String::new(),
255 };
256 for attr in e.attributes().flatten() {
257 if attr.key.as_ref() == b"ID" {
258 doc.id = String::from_utf8_lossy(&attr.value).to_string();
259 }
260 }
261 }
262 "Subject" | "DocumentDate" | "Amount" | "Folder" | "ContactName"
263 | "FileName" | "Reference"
264 if in_document =>
265 {
266 current_field = local;
267 }
268 _ => {}
269 }
270 }
271 Ok(Event::Text(ref e)) if in_document && !current_field.is_empty() => {
272 let text = e
273 .unescape()
274 .map_err(|e| YukiError::Xml(e.to_string()))?
275 .trim()
276 .to_string();
277 match current_field.as_str() {
278 "Subject" => doc.subject = text,
279 "DocumentDate" => doc.document_date = text,
280 "Amount" => doc.amount = text,
281 "Folder" => doc.folder = text,
282 "ContactName" => doc.contact_name = text,
283 "FileName" => doc.file_name = text,
284 "Reference" => doc.reference = text,
285 _ => {}
286 }
287 }
288 Ok(Event::End(ref e)) => {
289 let name = e.name();
290 let local = local_name(name.as_ref());
291 match local {
292 "Subject" | "DocumentDate" | "Amount" | "Folder" | "ContactName"
293 | "FileName" | "Reference" => {
294 current_field.clear();
295 }
296 "Document" => {
297 if !doc.id.is_empty() {
298 documents.push(doc.clone());
299 }
300 in_document = false;
301 }
302 _ => {}
303 }
304 }
305 Ok(Event::Eof) => break,
306 Err(e) => return Err(YukiError::Xml(e.to_string())),
307 _ => {}
308 }
309 buf.clear();
310 }
311
312 Ok(documents)
313 }
314
315 pub fn parse_cost_categories(xml: &str) -> Result<Vec<CostCategory>, YukiError> {
320 let mut reader = Reader::from_str(xml);
321 reader.config_mut().trim_text(true);
322
323 let mut categories = Vec::new();
324 let mut current_id = String::new();
325 let mut current_desc = String::new();
326 let mut in_category = false;
327 let mut in_description = false;
328 let mut buf = Vec::new();
329
330 loop {
331 match reader.read_event_into(&mut buf) {
332 Ok(Event::Start(ref e)) => {
333 let local = local_name(e.name().as_ref()).to_string();
334 match local.as_str() {
335 "CostCategory" => {
336 in_category = true;
337 current_id.clear();
338 current_desc.clear();
339 for attr in e.attributes().flatten() {
340 if attr.key.as_ref() == b"ID" {
341 current_id = String::from_utf8_lossy(&attr.value).to_string();
342 }
343 }
344 }
345 "Description" if in_category => {
346 in_description = true;
347 }
348 _ => {}
349 }
350 }
351 Ok(Event::Text(ref e)) if in_description => {
352 current_desc = e
353 .unescape()
354 .map_err(|e| YukiError::Xml(e.to_string()))?
355 .trim()
356 .to_string();
357 }
358 Ok(Event::End(ref e)) => {
359 let name = e.name();
360 let local = local_name(name.as_ref());
361 match local {
362 "Description" => in_description = false,
363 "CostCategory" => {
364 if !current_id.is_empty() {
365 categories.push(CostCategory {
366 id: current_id.clone(),
367 description: current_desc.clone(),
368 });
369 }
370 in_category = false;
371 }
372 _ => {}
373 }
374 }
375 Ok(Event::Eof) => break,
376 Err(e) => return Err(YukiError::Xml(e.to_string())),
377 _ => {}
378 }
379 buf.clear();
380 }
381
382 Ok(categories)
383 }
384
385 pub fn parse_payment_methods(xml: &str) -> Result<Vec<PaymentMethod>, YukiError> {
390 let mut reader = Reader::from_str(xml);
391 reader.config_mut().trim_text(true);
392
393 let mut methods = Vec::new();
394 let mut current_id = String::new();
395 let mut current_desc = String::new();
396 let mut in_method = false;
397 let mut in_description = false;
398 let mut buf = Vec::new();
399
400 loop {
401 match reader.read_event_into(&mut buf) {
402 Ok(Event::Start(ref e)) => {
403 let local = local_name(e.name().as_ref()).to_string();
404 match local.as_str() {
405 "PaymentMethod" => {
406 in_method = true;
407 current_id.clear();
408 current_desc.clear();
409 for attr in e.attributes().flatten() {
410 if attr.key.as_ref() == b"ID" {
411 current_id = String::from_utf8_lossy(&attr.value).to_string();
412 }
413 }
414 }
415 "Description" if in_method => {
416 in_description = true;
417 }
418 _ => {}
419 }
420 }
421 Ok(Event::Text(ref e)) if in_description => {
422 current_desc = e
423 .unescape()
424 .map_err(|e| YukiError::Xml(e.to_string()))?
425 .trim()
426 .to_string();
427 }
428 Ok(Event::End(ref e)) => {
429 let name = e.name();
430 let local = local_name(name.as_ref());
431 match local {
432 "Description" => in_description = false,
433 "PaymentMethod" => {
434 if !current_id.is_empty() {
435 methods.push(PaymentMethod {
436 id: current_id.clone(),
437 description: current_desc.clone(),
438 });
439 }
440 in_method = false;
441 }
442 _ => {}
443 }
444 }
445 Ok(Event::Eof) => break,
446 Err(e) => return Err(YukiError::Xml(e.to_string())),
447 _ => {}
448 }
449 buf.clear();
450 }
451
452 Ok(methods)
453 }
454}
455
456impl Default for ArchiveClient {
457 fn default() -> Self {
458 Self::new()
459 }
460}