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