use std::path::PathBuf; use che_orm::__private::sqlx; use che_tauri::{ApiError, TauriApi}; use serde::Deserialize; use crate::remote_http_client; #[derive(Debug, serde::Serialize)] pub struct SyncContractsResult { pub synced: usize, pub pages: usize, pub applications: usize, pub files_downloaded: usize, } #[derive(Debug, Deserialize)] struct ContractPage { links: PageLinks, results: Vec, } #[derive(Debug, Deserialize)] struct ContractCategoryPage { links: PageLinks, results: Vec, } #[derive(Debug, Deserialize)] struct PageLinks { next: Option, } #[derive(Debug, Deserialize)] struct RemoteContract { id: i64, name: String, number: String, #[serde(rename = "get_absolute_url")] absolute_url: String, #[serde(rename = "get_type")] contract_type: String, #[serde(rename = "get_category")] category: String, #[serde(default)] category_id: Option, #[serde(rename = "get_amount_total")] amount_total_display: String, #[serde(rename = "get_amount_by_ds")] amount_by_ds: String, comment: String, date: String, #[serde(rename = "get_status")] status_name: String, status: String, nds: String, name_of_product: String, counterparty: String, amount: f64, month_pay: Option, avans_pay: String, amount_total: f64, estimate_nds_cost: f64, estimate_nds_cert: f64, #[serde(rename = "get_bill_cost")] bill_cost: Option, income_total: f64, arrears: f64, company: Option, project: Option, frc: Option, get_employee: Option, } #[derive(Debug, Deserialize)] struct RemoteContractCategory { id: i64, name: String, name_group: String, template: Option, is_questionnair: bool, parent: Option, secure_group: Option, } #[derive(Debug, Deserialize)] struct RemoteBillCost { #[serde(rename = "cost__sum")] cost_sum: Option, #[serde(rename = "paid__sum")] paid_sum: Option, } #[derive(Debug, Deserialize)] struct RemoteContractDetail { #[serde(default)] contractapplicationfile_set: Vec, } #[derive(Debug, Deserialize)] struct RemoteContractApplicationFile { id: i64, name: String, file_type: String, #[serde(rename = "get_file_type_display")] file_type_display: String, status: String, #[serde(rename = "get_status_display")] status_display: String, #[serde(rename = "get_absolute_url")] absolute_url: String, #[serde(rename = "get_scan")] scan_name: String, scan: String, comment: String, bill_total: f64, bill_cost_total: f64, questionnair: Option, } #[derive(Debug, Deserialize)] struct RemoteCompany { id: i64, name: String, } #[derive(Debug, Deserialize)] struct RemoteProject { id: i64, name: String, full_name: String, #[serde(rename = "get_short_name")] short_name: String, locality: Option, } #[derive(Debug, Deserialize)] #[serde(untagged)] enum RemoteLocality { Object { id: i64, name: String }, Id(i64), } #[derive(Debug, Deserialize)] struct RemoteFrc { id: i64, name: String, icon: Option, #[serde(rename = "get_balance")] balance: Option, } #[derive(Debug, Deserialize)] struct RemoteEmployee { id: i64, name: String, short_name: String, avatar_small: Option, } pub async fn sync_contracts( api: &TauriApi, _app_data_dir: PathBuf, ) -> Result { let state = api.state(); let token = state .auth_token() .ok_or_else(|| ApiError::new("not_authenticated", "authentication token is missing"))?; let remote = state .remote_config() .ok_or_else(|| ApiError::bad_request("sync requires [remote].base_url config"))?; let client = remote_http_client(); sync_contract_categories(api, &client, &token, &remote.base_url).await?; let mut next_url = Some(format!( "{}/api/contract/?page_size=100", remote.base_url.trim_end_matches('/') )); let mut synced = 0; let mut pages = 0; let mut applications = 0; let mut files_downloaded = 0; while let Some(url) = next_url { let response = client .get(url) .header(reqwest::header::AUTHORIZATION, format!("Token {token}")) .send() .await?; if !response.status().is_success() { let status = response.status(); let detail = response.text().await.unwrap_or_default(); return Err(ApiError::new( "remote_error", format!("remote request failed with {status}: {detail}"), )); } let page = response.json::().await?; pages += 1; for contract in &page.results { upsert_contract_dependencies(api, contract).await?; upsert_contract(api, contract).await?; let detail = fetch_contract_detail(&client, &token, &remote.base_url, contract.id).await?; for application in &detail.contractapplicationfile_set { upsert_contract_application_file(api, contract.id, application, "").await?; applications += 1; if !application.scan.is_empty() { files_downloaded += 1; } } synced += 1; } next_url = page.links.next; } Ok(SyncContractsResult { synced, pages, applications, files_downloaded, }) } async fn sync_contract_categories( api: &TauriApi, client: &reqwest::Client, token: &str, base_url: &str, ) -> Result<(), ApiError> { let mut next_url = Some(format!( "{}/api/cont/category/?page_size=100", base_url.trim_end_matches('/') )); while let Some(url) = next_url { let response = client .get(url) .header(reqwest::header::AUTHORIZATION, format!("Token {token}")) .send() .await?; if !response.status().is_success() { let status = response.status(); let detail = response.text().await.unwrap_or_default(); return Err(ApiError::new( "remote_error", format!("remote category request failed with {status}: {detail}"), )); } let page = response.json::().await?; for category in &page.results { upsert_contract_category(api, category).await?; } next_url = page.links.next; } Ok(()) } async fn upsert_contract_category( api: &TauriApi, category: &RemoteContractCategory, ) -> Result<(), ApiError> { sqlx::query( "INSERT INTO contract_category ( id, name, name_group, template, is_questionnair, parent, secure_group ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(id) DO UPDATE SET name = excluded.name, name_group = excluded.name_group, template = excluded.template, is_questionnair = excluded.is_questionnair, parent = excluded.parent, secure_group = excluded.secure_group", ) .bind(category.id) .bind(&category.name) .bind(&category.name_group) .bind(category.template.as_deref()) .bind(category.is_questionnair) .bind(category.parent) .bind(category.secure_group) .execute(api.state().db().pool()) .await .map_err(database_error)?; Ok(()) } async fn fetch_contract_detail( client: &reqwest::Client, token: &str, base_url: &str, contract_id: i64, ) -> Result { let response = client .get(format!( "{}/api/contract/{contract_id}/", base_url.trim_end_matches('/') )) .header(reqwest::header::AUTHORIZATION, format!("Token {token}")) .send() .await?; if !response.status().is_success() { let status = response.status(); let detail = response.text().await.unwrap_or_default(); return Err(ApiError::new( "remote_error", format!("remote detail request failed with {status}: {detail}"), )); } Ok(response.json::().await?) } async fn upsert_contract_dependencies( api: &TauriApi, contract: &RemoteContract, ) -> Result<(), ApiError> { let pool = api.state().db().pool(); if let Some(company) = &contract.company { sqlx::query( "INSERT INTO counterparty (id, name) VALUES (?1, ?2) ON CONFLICT(id) DO UPDATE SET name = excluded.name", ) .bind(company.id) .bind(&company.name) .execute(pool) .await .map_err(database_error)?; } if let Some(project) = &contract.project { let (locality_id, locality_name) = match &project.locality { Some(RemoteLocality::Object { id, name }) => (Some(*id), Some(name.as_str())), Some(RemoteLocality::Id(id)) => (Some(*id), None), None => (None, None), }; sqlx::query( "INSERT INTO project (id, name, full_name, short_name, locality_id, locality_name) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(id) DO UPDATE SET name = excluded.name, full_name = excluded.full_name, short_name = excluded.short_name, locality_id = excluded.locality_id, locality_name = excluded.locality_name", ) .bind(project.id) .bind(&project.name) .bind(&project.full_name) .bind(&project.short_name) .bind(locality_id) .bind(locality_name) .execute(pool) .await .map_err(database_error)?; } if let Some(frc) = &contract.frc { sqlx::query( "INSERT INTO frc (id, name, icon, balance) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(id) DO UPDATE SET name = excluded.name, icon = excluded.icon, balance = excluded.balance", ) .bind(frc.id) .bind(&frc.name) .bind(frc.icon.as_deref().unwrap_or_default()) .bind(frc.balance.unwrap_or_default()) .execute(pool) .await .map_err(database_error)?; } if let Some(employee) = &contract.get_employee { sqlx::query( "INSERT INTO employee (id, name, short_name, avatar_small) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(id) DO UPDATE SET name = excluded.name, short_name = excluded.short_name, avatar_small = excluded.avatar_small", ) .bind(employee.id) .bind(&employee.name) .bind(&employee.short_name) .bind(employee.avatar_small.as_deref()) .execute(pool) .await .map_err(database_error)?; } Ok(()) } async fn upsert_contract(api: &TauriApi, contract: &RemoteContract) -> Result<(), ApiError> { sqlx::query( "INSERT INTO contractapp ( id, name, number, absolute_url, contract_type, category, category_id, amount_total_display, amount_by_ds, comment, date, status_name, status, nds, name_of_product, counterparty_name, amount, month_pay, avans_pay, amount_total, estimate_nds_cost, estimate_nds_cert, bill_cost_sum, bill_paid_sum, income_total, arrears, counterparty_id, project_id, frc_id, employee_id ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30 ) ON CONFLICT(id) DO UPDATE SET name = excluded.name, number = excluded.number, absolute_url = excluded.absolute_url, contract_type = excluded.contract_type, category = excluded.category, category_id = excluded.category_id, amount_total_display = excluded.amount_total_display, amount_by_ds = excluded.amount_by_ds, comment = excluded.comment, date = excluded.date, status_name = excluded.status_name, status = excluded.status, nds = excluded.nds, name_of_product = excluded.name_of_product, counterparty_name = excluded.counterparty_name, amount = excluded.amount, month_pay = excluded.month_pay, avans_pay = excluded.avans_pay, amount_total = excluded.amount_total, estimate_nds_cost = excluded.estimate_nds_cost, estimate_nds_cert = excluded.estimate_nds_cert, bill_cost_sum = excluded.bill_cost_sum, bill_paid_sum = excluded.bill_paid_sum, income_total = excluded.income_total, arrears = excluded.arrears, counterparty_id = excluded.counterparty_id, project_id = excluded.project_id, frc_id = excluded.frc_id, employee_id = excluded.employee_id", ) .bind(contract.id) .bind(&contract.name) .bind(&contract.number) .bind(&contract.absolute_url) .bind(&contract.contract_type) .bind(&contract.category) .bind(contract.category_id) .bind(&contract.amount_total_display) .bind(&contract.amount_by_ds) .bind(&contract.comment) .bind(&contract.date) .bind(&contract.status_name) .bind(&contract.status) .bind(&contract.nds) .bind(&contract.name_of_product) .bind(&contract.counterparty) .bind(contract.amount) .bind(contract.month_pay) .bind(&contract.avans_pay) .bind(contract.amount_total) .bind(contract.estimate_nds_cost) .bind(contract.estimate_nds_cert) .bind(contract.bill_cost.as_ref().and_then(|bill_cost| bill_cost.cost_sum)) .bind(contract.bill_cost.as_ref().and_then(|bill_cost| bill_cost.paid_sum)) .bind(contract.income_total) .bind(contract.arrears) .bind(contract.company.as_ref().map(|company| company.id)) .bind(contract.project.as_ref().map(|project| project.id)) .bind(contract.frc.as_ref().map(|frc| frc.id)) .bind(contract.get_employee.as_ref().map(|employee| employee.id)) .execute(api.state().db().pool()) .await .map_err(database_error)?; Ok(()) } async fn upsert_contract_application_file( api: &TauriApi, contract_id: i64, application: &RemoteContractApplicationFile, local_path: &str, ) -> Result<(), ApiError> { sqlx::query( "INSERT INTO contract_application_file ( id, contract_id, name, file_type, file_type_display, status, status_display, absolute_url, scan_name, scan_url, local_path, comment, bill_total, bill_cost_total, questionnair ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15) ON CONFLICT(id) DO UPDATE SET contract_id = excluded.contract_id, name = excluded.name, file_type = excluded.file_type, file_type_display = excluded.file_type_display, status = excluded.status, status_display = excluded.status_display, absolute_url = excluded.absolute_url, scan_name = excluded.scan_name, scan_url = excluded.scan_url, local_path = excluded.local_path, comment = excluded.comment, bill_total = excluded.bill_total, bill_cost_total = excluded.bill_cost_total, questionnair = excluded.questionnair", ) .bind(application.id) .bind(contract_id) .bind(&application.name) .bind(&application.file_type) .bind(&application.file_type_display) .bind(&application.status) .bind(&application.status_display) .bind(&application.absolute_url) .bind(&application.scan_name) .bind(&application.scan) .bind(local_path) .bind(&application.comment) .bind(application.bill_total) .bind(application.bill_cost_total) .bind(application.questionnair) .execute(api.state().db().pool()) .await .map_err(database_error)?; Ok(()) } fn database_error(error: sqlx::Error) -> ApiError { ApiError::new("database_error", error.to_string()) }