543 lines
16 KiB
Rust
543 lines
16 KiB
Rust
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<RemoteContract>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ContractCategoryPage {
|
|
links: PageLinks,
|
|
results: Vec<RemoteContractCategory>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct PageLinks {
|
|
next: Option<String>,
|
|
}
|
|
|
|
#[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<i64>,
|
|
#[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<f64>,
|
|
avans_pay: String,
|
|
amount_total: f64,
|
|
estimate_nds_cost: f64,
|
|
estimate_nds_cert: f64,
|
|
#[serde(rename = "get_bill_cost")]
|
|
bill_cost: Option<RemoteBillCost>,
|
|
income_total: f64,
|
|
arrears: f64,
|
|
company: Option<RemoteCompany>,
|
|
project: Option<RemoteProject>,
|
|
frc: Option<RemoteFrc>,
|
|
get_employee: Option<RemoteEmployee>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RemoteContractCategory {
|
|
id: i64,
|
|
name: String,
|
|
name_group: String,
|
|
template: Option<String>,
|
|
is_questionnair: bool,
|
|
parent: Option<i64>,
|
|
secure_group: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RemoteBillCost {
|
|
#[serde(rename = "cost__sum")]
|
|
cost_sum: Option<f64>,
|
|
#[serde(rename = "paid__sum")]
|
|
paid_sum: Option<f64>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RemoteContractDetail {
|
|
#[serde(default)]
|
|
contractapplicationfile_set: Vec<RemoteContractApplicationFile>,
|
|
}
|
|
|
|
#[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<i64>,
|
|
}
|
|
|
|
#[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<RemoteLocality>,
|
|
}
|
|
|
|
#[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<String>,
|
|
#[serde(rename = "get_balance")]
|
|
balance: Option<f64>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RemoteEmployee {
|
|
id: i64,
|
|
name: String,
|
|
short_name: String,
|
|
avatar_small: Option<String>,
|
|
}
|
|
|
|
pub async fn sync_contracts(
|
|
api: &TauriApi,
|
|
_app_data_dir: PathBuf,
|
|
) -> Result<SyncContractsResult, ApiError> {
|
|
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::<ContractPage>().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::<ContractCategoryPage>().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<RemoteContractDetail, ApiError> {
|
|
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::<RemoteContractDetail>().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())
|
|
}
|