fix
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
use che_tauri::{Filter, FilterSet};
|
use che_tauri::{Filter, FilterSet};
|
||||||
|
|
||||||
use super::models::{Contract, Counterparty};
|
use super::models::{Contract, ContractApplicationFile, Counterparty};
|
||||||
|
|
||||||
static CONTRACTAPP_FILTERS: &[Filter] = &[
|
static CONTRACTAPP_FILTERS: &[Filter] = &[
|
||||||
Filter::exact("id"),
|
Filter::exact("id"),
|
||||||
@@ -30,6 +30,15 @@ static COUNTERPARTY_FILTERS: &[Filter] = &[
|
|||||||
Filter::contains("name"),
|
Filter::contains("name"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
static CONTRACT_APPLICATION_FILE_FILTERS: &[Filter] = &[
|
||||||
|
Filter::exact("id"),
|
||||||
|
Filter::exact("contract_id"),
|
||||||
|
Filter::exact("name"),
|
||||||
|
Filter::contains("name"),
|
||||||
|
Filter::exact("file_type"),
|
||||||
|
Filter::exact("status"),
|
||||||
|
];
|
||||||
|
|
||||||
pub fn contractapp_filterset() -> FilterSet<Contract> {
|
pub fn contractapp_filterset() -> FilterSet<Contract> {
|
||||||
FilterSet::new(CONTRACTAPP_FILTERS)
|
FilterSet::new(CONTRACTAPP_FILTERS)
|
||||||
}
|
}
|
||||||
@@ -37,3 +46,7 @@ pub fn contractapp_filterset() -> FilterSet<Contract> {
|
|||||||
pub fn counterparty_filterset() -> FilterSet<Counterparty> {
|
pub fn counterparty_filterset() -> FilterSet<Counterparty> {
|
||||||
FilterSet::new(COUNTERPARTY_FILTERS)
|
FilterSet::new(COUNTERPARTY_FILTERS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn contract_application_file_filterset() -> FilterSet<ContractApplicationFile> {
|
||||||
|
FilterSet::new(CONTRACT_APPLICATION_FILE_FILTERS)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS contract_application_file (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
contract_id INTEGER NOT NULL REFERENCES contractapp(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
file_type TEXT NOT NULL,
|
||||||
|
file_type_display TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
status_display TEXT NOT NULL,
|
||||||
|
absolute_url TEXT NOT NULL,
|
||||||
|
scan_name TEXT NOT NULL,
|
||||||
|
scan_url TEXT NOT NULL,
|
||||||
|
local_path TEXT NOT NULL,
|
||||||
|
comment TEXT NOT NULL,
|
||||||
|
bill_total REAL NOT NULL,
|
||||||
|
bill_cost_total REAL NOT NULL,
|
||||||
|
questionnair INTEGER
|
||||||
|
);
|
||||||
@@ -1,5 +1,178 @@
|
|||||||
{
|
{
|
||||||
"models": [
|
"models": [
|
||||||
|
{
|
||||||
|
"table": "contract_application_file",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ty": "integer",
|
||||||
|
"primary_key": true,
|
||||||
|
"nullable": false,
|
||||||
|
"auto": true,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "contract_id",
|
||||||
|
"ty": "integer",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": false,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": {
|
||||||
|
"table": "contractapp",
|
||||||
|
"column": "id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"ty": "text",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": false,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "file_type",
|
||||||
|
"ty": "text",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": false,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "file_type_display",
|
||||||
|
"ty": "text",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": false,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"ty": "text",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": false,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "status_display",
|
||||||
|
"ty": "text",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": false,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "absolute_url",
|
||||||
|
"ty": "text",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": false,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "scan_name",
|
||||||
|
"ty": "text",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": false,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "scan_url",
|
||||||
|
"ty": "text",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": false,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "local_path",
|
||||||
|
"ty": "text",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": false,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment",
|
||||||
|
"ty": "text",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": false,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bill_total",
|
||||||
|
"ty": "real",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": false,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bill_cost_total",
|
||||||
|
"ty": "real",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": false,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "questionnair",
|
||||||
|
"ty": "integer",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": true,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"table": "contractapp",
|
"table": "contractapp",
|
||||||
"fields": [
|
"fields": [
|
||||||
|
|||||||
@@ -26,5 +26,10 @@ impl AppModule for ContractappModule {
|
|||||||
serializers::counterparty_serializer(),
|
serializers::counterparty_serializer(),
|
||||||
filters::counterparty_filterset(),
|
filters::counterparty_filterset(),
|
||||||
);
|
);
|
||||||
|
ctx.resource::<models::ContractApplicationFile>(
|
||||||
|
"contract_application_file",
|
||||||
|
serializers::contract_application_file_serializer(),
|
||||||
|
filters::contract_application_file_filterset(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,3 +56,27 @@ pub struct Counterparty {
|
|||||||
|
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Model)]
|
||||||
|
#[model(table = "contract_application_file")]
|
||||||
|
pub struct ContractApplicationFile {
|
||||||
|
#[field(primary_key)]
|
||||||
|
pub id: i64,
|
||||||
|
|
||||||
|
#[field(foreign_key = Contract)]
|
||||||
|
pub contract_id: i64,
|
||||||
|
|
||||||
|
pub name: String,
|
||||||
|
pub file_type: String,
|
||||||
|
pub file_type_display: String,
|
||||||
|
pub status: String,
|
||||||
|
pub status_display: String,
|
||||||
|
pub absolute_url: String,
|
||||||
|
pub scan_name: String,
|
||||||
|
pub scan_url: String,
|
||||||
|
pub local_path: String,
|
||||||
|
pub comment: String,
|
||||||
|
pub bill_total: f64,
|
||||||
|
pub bill_cost_total: f64,
|
||||||
|
pub questionnair: Option<i64>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::apps::{
|
|||||||
projectapp::{models::Project, serializers::project_serializer},
|
projectapp::{models::Project, serializers::project_serializer},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::models::{Contract, Counterparty};
|
use super::models::{Contract, ContractApplicationFile, Counterparty};
|
||||||
|
|
||||||
static CONTRACT_FIELDS: &[Field] = &[
|
static CONTRACT_FIELDS: &[Field] = &[
|
||||||
Field::new("id").read_only(),
|
Field::new("id").read_only(),
|
||||||
@@ -45,8 +45,27 @@ static CONTRACT_FIELDS: &[Field] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
static COUNTERPARTY_FIELDS: &[Field] = &[Field::new("id").read_only(), Field::new("name")];
|
static COUNTERPARTY_FIELDS: &[Field] = &[Field::new("id").read_only(), Field::new("name")];
|
||||||
|
static CONTRACT_APPLICATION_FILE_FIELDS: &[Field] = &[
|
||||||
|
Field::new("id").read_only(),
|
||||||
|
Field::new("contract_id"),
|
||||||
|
Field::new("name"),
|
||||||
|
Field::new("file_type"),
|
||||||
|
Field::new("file_type_display"),
|
||||||
|
Field::new("status"),
|
||||||
|
Field::new("status_display"),
|
||||||
|
Field::new("absolute_url"),
|
||||||
|
Field::new("scan_name"),
|
||||||
|
Field::new("scan_url"),
|
||||||
|
Field::new("local_path"),
|
||||||
|
Field::new("comment"),
|
||||||
|
Field::new("bill_total"),
|
||||||
|
Field::new("bill_cost_total"),
|
||||||
|
Field::new("questionnair").required(false).nullable(),
|
||||||
|
Field::related("contract", "contract_id", &CONTRACT_RELATION),
|
||||||
|
];
|
||||||
static COUNTERPARTY_RELATION: RelatedModel<Counterparty> =
|
static COUNTERPARTY_RELATION: RelatedModel<Counterparty> =
|
||||||
RelatedModel::new(counterparty_serializer);
|
RelatedModel::new(counterparty_serializer);
|
||||||
|
static CONTRACT_RELATION: RelatedModel<Contract> = RelatedModel::new(contractapp_serializer);
|
||||||
static PROJECT_RELATION: RelatedModel<Project> = RelatedModel::new(project_serializer);
|
static PROJECT_RELATION: RelatedModel<Project> = RelatedModel::new(project_serializer);
|
||||||
static FRC_RELATION: RelatedModel<Frc> = RelatedModel::new(frc_serializer);
|
static FRC_RELATION: RelatedModel<Frc> = RelatedModel::new(frc_serializer);
|
||||||
static EMPLOYEE_RELATION: RelatedModel<Employee> = RelatedModel::new(employee_serializer);
|
static EMPLOYEE_RELATION: RelatedModel<Employee> = RelatedModel::new(employee_serializer);
|
||||||
@@ -58,3 +77,7 @@ pub fn contractapp_serializer() -> ModelSerializer<Contract> {
|
|||||||
pub fn counterparty_serializer() -> ModelSerializer<Counterparty> {
|
pub fn counterparty_serializer() -> ModelSerializer<Counterparty> {
|
||||||
ModelSerializer::new(COUNTERPARTY_FIELDS)
|
ModelSerializer::new(COUNTERPARTY_FIELDS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn contract_application_file_serializer() -> ModelSerializer<ContractApplicationFile> {
|
||||||
|
ModelSerializer::new(CONTRACT_APPLICATION_FILE_FIELDS)
|
||||||
|
}
|
||||||
|
|||||||
+10
-2
@@ -2,6 +2,7 @@ pub mod apps;
|
|||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
|
||||||
use che_tauri::{ApiError, ApiRequest, AppState, AuthTokenResponse, TauriApi};
|
use che_tauri::{ApiError, ApiRequest, AppState, AuthTokenResponse, TauriApi};
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
use crate::sync::SyncContractsResult;
|
use crate::sync::SyncContractsResult;
|
||||||
|
|
||||||
@@ -43,8 +44,15 @@ fn auth_status(api: tauri::State<'_, TauriApi>) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn sync_contracts(api: tauri::State<'_, TauriApi>) -> Result<SyncContractsResult, ApiError> {
|
async fn sync_contracts(
|
||||||
sync::sync_contracts(&api).await
|
app: tauri::AppHandle,
|
||||||
|
api: tauri::State<'_, TauriApi>,
|
||||||
|
) -> Result<SyncContractsResult, ApiError> {
|
||||||
|
let app_data_dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|error| ApiError::new("file_error", error.to_string()))?;
|
||||||
|
sync::sync_contracts(&api, app_data_dir).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
|||||||
+196
-2
@@ -1,3 +1,5 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use che_orm::__private::sqlx;
|
use che_orm::__private::sqlx;
|
||||||
use che_tauri::{ApiError, TauriApi};
|
use che_tauri::{ApiError, TauriApi};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -6,6 +8,8 @@ use serde::Deserialize;
|
|||||||
pub struct SyncContractsResult {
|
pub struct SyncContractsResult {
|
||||||
pub synced: usize,
|
pub synced: usize,
|
||||||
pub pages: usize,
|
pub pages: usize,
|
||||||
|
pub applications: usize,
|
||||||
|
pub files_downloaded: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -66,6 +70,33 @@ struct RemoteBillCost {
|
|||||||
paid_sum: Option<f64>,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct RemoteCompany {
|
struct RemoteCompany {
|
||||||
id: i64,
|
id: i64,
|
||||||
@@ -106,7 +137,10 @@ struct RemoteEmployee {
|
|||||||
avatar_small: Option<String>,
|
avatar_small: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn sync_contracts(api: &TauriApi) -> Result<SyncContractsResult, ApiError> {
|
pub async fn sync_contracts(
|
||||||
|
api: &TauriApi,
|
||||||
|
app_data_dir: PathBuf,
|
||||||
|
) -> Result<SyncContractsResult, ApiError> {
|
||||||
let state = api.state();
|
let state = api.state();
|
||||||
let token = state
|
let token = state
|
||||||
.auth_token()
|
.auth_token()
|
||||||
@@ -123,6 +157,8 @@ pub async fn sync_contracts(api: &TauriApi) -> Result<SyncContractsResult, ApiEr
|
|||||||
));
|
));
|
||||||
let mut synced = 0;
|
let mut synced = 0;
|
||||||
let mut pages = 0;
|
let mut pages = 0;
|
||||||
|
let mut applications = 0;
|
||||||
|
let mut files_downloaded = 0;
|
||||||
|
|
||||||
while let Some(url) = next_url {
|
while let Some(url) = next_url {
|
||||||
let response = client
|
let response = client
|
||||||
@@ -146,13 +182,116 @@ pub async fn sync_contracts(api: &TauriApi) -> Result<SyncContractsResult, ApiEr
|
|||||||
for contract in &page.results {
|
for contract in &page.results {
|
||||||
upsert_contract_dependencies(api, contract).await?;
|
upsert_contract_dependencies(api, contract).await?;
|
||||||
upsert_contract(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 {
|
||||||
|
let local_path =
|
||||||
|
download_application_file(&client, &token, &app_data_dir, application).await?;
|
||||||
|
upsert_contract_application_file(api, contract.id, application, &local_path).await?;
|
||||||
|
applications += 1;
|
||||||
|
if !local_path.is_empty() {
|
||||||
|
files_downloaded += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
synced += 1;
|
synced += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
next_url = page.links.next;
|
next_url = page.links.next;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(SyncContractsResult { synced, pages })
|
Ok(SyncContractsResult {
|
||||||
|
synced,
|
||||||
|
pages,
|
||||||
|
applications,
|
||||||
|
files_downloaded,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 download_application_file(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
app_data_dir: &PathBuf,
|
||||||
|
application: &RemoteContractApplicationFile,
|
||||||
|
) -> Result<String, ApiError> {
|
||||||
|
if application.scan.is_empty() {
|
||||||
|
return Ok(String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let directory = application_download_dir(app_data_dir);
|
||||||
|
tokio::fs::create_dir_all(&directory)
|
||||||
|
.await
|
||||||
|
.map_err(file_error)?;
|
||||||
|
|
||||||
|
let file_name = format!("{}_{}", application.id, sanitize_file_name(&application.scan_name));
|
||||||
|
let path = directory.join(file_name);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&application.scan)
|
||||||
|
.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!("file download failed with {status}: {detail}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = response.bytes().await?;
|
||||||
|
tokio::fs::write(&path, bytes).await.map_err(file_error)?;
|
||||||
|
|
||||||
|
Ok(path.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn application_download_dir(app_data_dir: &PathBuf) -> PathBuf {
|
||||||
|
app_data_dir.join("contract_applications")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_file_name(file_name: &str) -> String {
|
||||||
|
let sanitized = file_name
|
||||||
|
.chars()
|
||||||
|
.map(|character| match character {
|
||||||
|
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||||
|
_ => character,
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
if sanitized.is_empty() {
|
||||||
|
"application_file".to_string()
|
||||||
|
} else {
|
||||||
|
sanitized
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn upsert_contract_dependencies(
|
async fn upsert_contract_dependencies(
|
||||||
@@ -316,6 +455,61 @@ async fn upsert_contract(api: &TauriApi, contract: &RemoteContract) -> Result<()
|
|||||||
Ok(())
|
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 {
|
fn database_error(error: sqlx::Error) -> ApiError {
|
||||||
ApiError::new("database_error", error.to_string())
|
ApiError::new("database_error", error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn file_error(error: impl std::fmt::Display) -> ApiError {
|
||||||
|
ApiError::new("file_error", error.to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import type {
|
|||||||
ContractCreate,
|
ContractCreate,
|
||||||
ContractUpdate,
|
ContractUpdate,
|
||||||
ContractListParams,
|
ContractListParams,
|
||||||
|
ContractApplicationFile,
|
||||||
|
ContractApplicationFileCreate,
|
||||||
|
ContractApplicationFileUpdate,
|
||||||
|
ContractApplicationFileListParams,
|
||||||
Counterparty,
|
Counterparty,
|
||||||
CounterpartyCreate,
|
CounterpartyCreate,
|
||||||
CounterpartyUpdate,
|
CounterpartyUpdate,
|
||||||
@@ -35,6 +39,7 @@ import type {
|
|||||||
} from "./models";
|
} from "./models";
|
||||||
|
|
||||||
export const contractApi = createModelApi<Contract, ContractCreate, ContractUpdate, ContractListParams>("contract");
|
export const contractApi = createModelApi<Contract, ContractCreate, ContractUpdate, ContractListParams>("contract");
|
||||||
|
export const contractApplicationFileApi = createModelApi<ContractApplicationFile, ContractApplicationFileCreate, ContractApplicationFileUpdate, ContractApplicationFileListParams>("contract_application_file");
|
||||||
export const counterpartyApi = createModelApi<Counterparty, CounterpartyCreate, CounterpartyUpdate, CounterpartyListParams>("counterparty");
|
export const counterpartyApi = createModelApi<Counterparty, CounterpartyCreate, CounterpartyUpdate, CounterpartyListParams>("counterparty");
|
||||||
export const employeeApi = createModelApi<Employee, EmployeeCreate, EmployeeUpdate, EmployeeListParams>("employee");
|
export const employeeApi = createModelApi<Employee, EmployeeCreate, EmployeeUpdate, EmployeeListParams>("employee");
|
||||||
export const frcApi = createModelApi<Frc, FrcCreate, FrcUpdate, FrcListParams>("frc");
|
export const frcApi = createModelApi<Frc, FrcCreate, FrcUpdate, FrcListParams>("frc");
|
||||||
|
|||||||
@@ -120,6 +120,68 @@ export interface ContractListParams extends ListParams {
|
|||||||
employee_id?: number | null;
|
employee_id?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContractApplicationFile {
|
||||||
|
id: number;
|
||||||
|
contract_id: number;
|
||||||
|
name: string;
|
||||||
|
file_type: string;
|
||||||
|
file_type_display: string;
|
||||||
|
status: string;
|
||||||
|
status_display: string;
|
||||||
|
absolute_url: string;
|
||||||
|
scan_name: string;
|
||||||
|
scan_url: string;
|
||||||
|
local_path: string;
|
||||||
|
comment: string;
|
||||||
|
bill_total: number;
|
||||||
|
bill_cost_total: number;
|
||||||
|
questionnair: number | null;
|
||||||
|
contract: Contract;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractApplicationFileCreate {
|
||||||
|
contract_id: number;
|
||||||
|
name: string;
|
||||||
|
file_type: string;
|
||||||
|
file_type_display: string;
|
||||||
|
status: string;
|
||||||
|
status_display: string;
|
||||||
|
absolute_url: string;
|
||||||
|
scan_name: string;
|
||||||
|
scan_url: string;
|
||||||
|
local_path: string;
|
||||||
|
comment: string;
|
||||||
|
bill_total: number;
|
||||||
|
bill_cost_total: number;
|
||||||
|
questionnair?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractApplicationFileUpdate {
|
||||||
|
contract_id?: number;
|
||||||
|
name?: string;
|
||||||
|
file_type?: string;
|
||||||
|
file_type_display?: string;
|
||||||
|
status?: string;
|
||||||
|
status_display?: string;
|
||||||
|
absolute_url?: string;
|
||||||
|
scan_name?: string;
|
||||||
|
scan_url?: string;
|
||||||
|
local_path?: string;
|
||||||
|
comment?: string;
|
||||||
|
bill_total?: number;
|
||||||
|
bill_cost_total?: number;
|
||||||
|
questionnair?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractApplicationFileListParams extends ListParams {
|
||||||
|
id?: number;
|
||||||
|
contract_id?: number;
|
||||||
|
name?: string;
|
||||||
|
name__contains?: string;
|
||||||
|
file_type?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Counterparty {
|
export interface Counterparty {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { openPath } from "@tauri-apps/plugin-opener";
|
||||||
import { computed, onMounted } from "vue";
|
import { computed, onMounted } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { contractApi } from "../generated/api";
|
import { showToast } from "vant";
|
||||||
import type { Contract } from "../generated/models";
|
import { contractApi, contractApplicationFileApi } from "../generated/api";
|
||||||
|
import type { Contract, ContractApplicationFileListParams } from "../generated/models";
|
||||||
import { useModelApi } from "../composables/useModelApi";
|
import { useModelApi } from "../composables/useModelApi";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -19,6 +21,19 @@ const {
|
|||||||
autoLoad: false,
|
autoLoad: false,
|
||||||
autoLoadOnFilterChange: false,
|
autoLoadOnFilterChange: false,
|
||||||
});
|
});
|
||||||
|
const {
|
||||||
|
items: applicationFiles,
|
||||||
|
loading: loadingApplicationFiles,
|
||||||
|
error: applicationFilesError,
|
||||||
|
load: loadApplicationFiles,
|
||||||
|
} = useModelApi(contractApplicationFileApi, {
|
||||||
|
defaultListParams: { ordering: "id" } as ContractApplicationFileListParams,
|
||||||
|
loadErrorMessage: "Не удалось загрузить приложения",
|
||||||
|
autoLoad: false,
|
||||||
|
autoLoadOnFilterChange: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewError = computed(() => error.value || applicationFilesError.value);
|
||||||
|
|
||||||
type DetailField = {
|
type DetailField = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -87,23 +102,48 @@ function formatValue(value: unknown) {
|
|||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openApplicationFile(localPath: string) {
|
||||||
|
if (!localPath) {
|
||||||
|
showToast("Файл не скачан");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openPath(localPath);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(errorMessage(err, "Не удалось открыть файл"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(err: unknown, fallback: string) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return err.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (Number.isFinite(contractId.value)) {
|
if (Number.isFinite(contractId.value)) {
|
||||||
loadContract(contractId.value);
|
loadContract(contractId.value);
|
||||||
|
loadApplicationFiles({
|
||||||
|
ordering: "id",
|
||||||
|
contract_id: contractId.value,
|
||||||
|
} as ContractApplicationFileListParams);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<van-notice-bar
|
<van-notice-bar
|
||||||
v-if="error"
|
v-if="viewError"
|
||||||
class="notice"
|
class="notice"
|
||||||
color="#991b1b"
|
color="#991b1b"
|
||||||
background="#fee2e2"
|
background="#fee2e2"
|
||||||
left-icon="warning-o"
|
left-icon="warning-o"
|
||||||
wrapable
|
wrapable
|
||||||
:scrollable="false"
|
:scrollable="false"
|
||||||
:text="error"
|
:text="viewError"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section class="card detail-card">
|
<section class="card detail-card">
|
||||||
@@ -139,6 +179,30 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
|
<van-cell-group inset title="Приложения">
|
||||||
|
<van-cell v-if="loadingApplicationFiles" title="Загрузка приложений..." />
|
||||||
|
<van-cell v-else-if="applicationFiles.length === 0" title="Приложений нет" />
|
||||||
|
<van-cell
|
||||||
|
v-for="file in applicationFiles"
|
||||||
|
v-else
|
||||||
|
:key="file.id"
|
||||||
|
:title="file.name"
|
||||||
|
:label="`${file.file_type_display} · ${file.status_display} · ${file.scan_name}`"
|
||||||
|
>
|
||||||
|
<template #value>
|
||||||
|
<van-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
:disabled="!file.local_path"
|
||||||
|
@click="openApplicationFile(file.local_path)"
|
||||||
|
>
|
||||||
|
Открыть
|
||||||
|
</van-button>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
<div class="detail-actions">
|
<div class="detail-actions">
|
||||||
<van-button block round type="primary" plain @click="router.back()">Назад</van-button>
|
<van-button block round type="primary" plain @click="router.back()">Назад</van-button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ const currentPage = computed({
|
|||||||
interface SyncContractsResult {
|
interface SyncContractsResult {
|
||||||
synced: number;
|
synced: number;
|
||||||
pages: number;
|
pages: number;
|
||||||
|
applications: number;
|
||||||
|
files_downloaded: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncing = ref(false);
|
const syncing = ref(false);
|
||||||
@@ -68,7 +70,9 @@ async function syncContracts() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await invoke<SyncContractsResult>("sync_contracts");
|
const result = await invoke<SyncContractsResult>("sync_contracts");
|
||||||
showToast(`Синхронизировано: ${result.synced}`);
|
showToast(
|
||||||
|
`Синхронизировано: ${result.synced}; приложений: ${result.applications}; файлов: ${result.files_downloaded}`,
|
||||||
|
);
|
||||||
if (filters.offset) {
|
if (filters.offset) {
|
||||||
filters.offset = 0;
|
filters.offset = 0;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user