Dualside printing test realisation

This commit is contained in:
2026-09-17 18:59:00 +03:00
parent 4256cd0a94
commit 6ad460fb02

View File

@ -25,7 +25,7 @@ use tracing::{error, info, warn};
async fn get_version() -> impl IntoResponse { async fn get_version() -> impl IntoResponse {
Json(serde_json::json!({ Json(serde_json::json!({
"version": "1.0.1", "version": "1.0.2",
"timestamp": chrono::Utc::now().timestamp() "timestamp": chrono::Utc::now().timestamp()
})) }))
} }
@ -51,6 +51,7 @@ struct PrintSettings {
enum FileStatus { enum FileStatus {
Queued, Queued,
Printing, Printing,
AwaitingFlip,
Awaiting, Awaiting,
Done, Done,
Error, Error,
@ -62,6 +63,7 @@ enum FileStatus {
enum JobPhase { enum JobPhase {
WaitingStart, WaitingStart,
Printing, Printing,
AwaitingFlip,
AwaitingPickup, AwaitingPickup,
Finished, Finished,
Cancelled, Cancelled,
@ -73,6 +75,9 @@ struct JobFileView {
status: FileStatus, status: FileStatus,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>, error: Option<String>,
/// true → нечётное кол-во страниц, нужно убрать верхний лист перед 2-м проходом
#[serde(default)]
remove_top_sheet: bool,
} }
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
@ -103,16 +108,28 @@ impl Job {
JobView { JobView {
job_id: self.id.clone(), job_id: self.id.clone(),
phase: self.phase.clone(), phase: self.phase.clone(),
files: self files: self.files.iter().map(|f| {
.files JobFileView {
.iter()
.map(|f| JobFileView {
name: f.name.clone(), name: f.name.clone(),
status: f.status.clone(), status: f.status.clone(),
error: f.error.clone(), error: f.error.clone(),
}) remove_top_sheet: Self::should_remove_top_sheet(&f.settings),
.collect(),
} }
}).collect(),
}
}
/// Нечетных страниц больше, чем чётных → лишний лист сверху
fn should_remove_top_sheet(settings: &Option<PrintSettings>) -> bool {
let Some(s) = settings else { return false };
if s.sides.as_deref() != Some("Двусторонняя") { return false; }
if s.pages == "all" { return false; } // для офисных не знаем кол-во
let nums: Vec<u32> = s.pages.split(',')
.filter_map(|p| p.trim().parse().ok())
.collect();
let odd = nums.iter().filter(|n| *n % 2 == 1).count();
let even = nums.iter().filter(|n| *n % 2 == 0).count();
odd > even
} }
} }
@ -122,6 +139,7 @@ struct SpawnInfo {
idx: usize, idx: usize,
path: PathBuf, path: PathBuf,
settings: Option<PrintSettings>, settings: Option<PrintSettings>,
is_second_pass: bool,
} }
type JobStore = Arc<Mutex<HashMap<String, Job>>>; type JobStore = Arc<Mutex<HashMap<String, Job>>>;
@ -279,30 +297,48 @@ async fn job_advance(
) -> Result<Json<JobView>, (StatusCode, String)> { ) -> Result<Json<JobView>, (StatusCode, String)> {
let (view, spawn_info) = { let (view, spawn_info) = {
let mut m = store.lock().unwrap(); let mut m = store.lock().unwrap();
let job = m let job = m.get_mut(&job_id)
.get_mut(&job_id)
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Задание {} не найдено", job_id)))?; .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Задание {} не найдено", job_id)))?;
let si = match job.phase { let si = match job.phase {
JobPhase::WaitingStart => { JobPhase::WaitingStart => {
info!(job_id = %job_id, "▶️ Клиент дал старт: печать первого файла"); info!(job_id = %job_id, "▶️ Старт первого файла");
Some(start_printing_locked(job, 0)) Some(start_printing_locked(job, 0))
} }
JobPhase::AwaitingFlip => {
if let Some(c) = job.current {
info!(job_id = %job_id, file = %job.files[c].name,
"🔄 Запуск второго прохода (чётные страницы)");
job.files[c].status = FileStatus::Printing;
job.phase = JobPhase::Printing;
job.cups_id = None;
Some(SpawnInfo {
job_id: job.id.clone(),
idx: c,
path: job.files[c].path.to_path_buf(),
settings: job.files[c].settings.clone(),
is_second_pass: true,
})
} else {
None
}
}
JobPhase::AwaitingPickup => match job.current.take() { JobPhase::AwaitingPickup => match job.current.take() {
Some(c) => { Some(c) => {
job.files[c].status = FileStatus::Done; job.files[c].status = FileStatus::Done;
info!(job_id = %job_id, file = %job.files[c].name, "✔️ Пользователь забрал документ"); info!(job_id = %job_id, file = %job.files[c].name, "✔️ Документ забран");
begin_next_locked(job, c) begin_next_locked(job, c)
} }
None => { None => { job.phase = JobPhase::Finished; None }
job.phase = JobPhase::Finished;
None
}
}, },
// printing / finished / cancelled — идемпотентно игнорируем
_ => None, _ => None,
}; };
(job.view(), si) (job.view(), si)
}; };
if let Some(si) = spawn_info { if let Some(si) = spawn_info {
spawn_print_task(store.clone(), si); spawn_print_task(store.clone(), si);
} }
@ -362,6 +398,7 @@ fn start_printing_locked(job: &mut Job, idx: usize) -> SpawnInfo {
idx, idx,
path: job.files[idx].path.to_path_buf(), path: job.files[idx].path.to_path_buf(),
settings: job.files[idx].settings.clone(), settings: job.files[idx].settings.clone(),
is_second_pass: false,
} }
} }
@ -383,54 +420,71 @@ fn spawn_print_task(store: JobStore, info: SpawnInfo) {
let path = info.path.clone(); let path = info.path.clone();
let settings = info.settings.clone(); let settings = info.settings.clone();
let is_duplex = settings.as_ref()
.map(|s| s.sides.as_deref() == Some("Двусторонняя"))
.unwrap_or(false);
let duplex_pass: Option<&str> = if is_duplex {
if info.is_second_pass { Some("even") } else { Some("odd") }
} else {
None
};
// 1) Спулим в CUPS // 1) Спулим в CUPS
let spool = let p = path.clone();
tokio::task::spawn_blocking(move || run_lp(&path, settings.as_ref())).await; let s = settings.clone();
let dp = duplex_pass.map(|d| d.to_string());
let spool = tokio::task::spawn_blocking(move || {
run_lp(&p, s.as_ref(), dp.as_deref())
}).await;
let cups_id = match spool { let cups_id = match spool {
Ok(Ok(id)) => id, Ok(Ok(id)) => id,
Ok(Err(e)) => return mark_error_and_continue(store, info, e).await, Ok(Err(e)) => return mark_error_and_continue(store, info, e).await,
Err(e) => { Err(e) => return mark_error_and_continue(store, info, format!("spawn: {}", e)).await,
return mark_error_and_continue(store, info, format!("spawn: {}", e)).await
}
}; };
// 2) Ждём физического завершения печати // 2) Ждём физического завершения
if !cups_id.is_empty() { if !cups_id.is_empty() {
{ {
let mut m = store.lock().unwrap(); let mut m = store.lock().unwrap();
match m.get_mut(&info.job_id) { if let Some(job) = m.get_mut(&info.job_id) {
Some(job) if job.phase != JobPhase::Cancelled => { if job.phase != JobPhase::Cancelled {
job.cups_id = Some(cups_id.clone()) job.cups_id = Some(cups_id.clone());
} } else { return; }
_ => return, } else { return; }
}
} }
let cid = cups_id.clone(); let cid = cups_id.clone();
let wait = tokio::task::spawn_blocking(move || wait_for_cups(&cid)).await; let wait = tokio::task::spawn_blocking(move || wait_for_cups(&cid)).await;
match wait { match wait {
Ok(Ok(())) => {} Ok(Ok(())) => {}
Ok(Err(e)) => return mark_error_and_continue(store, info, e).await, Ok(Err(e)) => return mark_error_and_continue(store, info, e).await,
Err(e) => { Err(e) => return mark_error_and_continue(store, info, format!("spawn: {}", e)).await,
return mark_error_and_continue(store, info, format!("spawn: {}", e)).await
}
} }
} }
// 3) Допечатано → ждём подтверждения пользователя // 3) Обновляем статус
let mut m = store.lock().unwrap(); let mut m = store.lock().unwrap();
let job = match m.get_mut(&info.job_id) { let job = match m.get_mut(&info.job_id) { Some(j) => j, None => return };
Some(j) => j,
None => return, if job.phase == JobPhase::Cancelled { return; }
}; if job.files.get(info.idx).map(|f| f.status.clone()) != Some(FileStatus::Printing) { return; }
if job.phase == JobPhase::Cancelled
|| job.files.get(info.idx).map(|f| f.status.clone()) != Some(FileStatus::Printing) if is_duplex && !info.is_second_pass {
{ // Первый проход завершён → ждём, пока пользователь переложит бумагу
return; job.files[info.idx].status = FileStatus::AwaitingFlip;
} job.phase = JobPhase::AwaitingFlip;
job.cups_id = None;
info!(job_id = %info.job_id, file = %job.files[info.idx].name,
"🔄 Первый проход завершён, ждём переворота бумаги");
} else {
// Обычная печать или второй проход дуплекса → ждём, пока заберут
job.files[info.idx].status = FileStatus::Awaiting; job.files[info.idx].status = FileStatus::Awaiting;
job.phase = JobPhase::AwaitingPickup; job.phase = JobPhase::AwaitingPickup;
job.cups_id = None; job.cups_id = None;
info!(job_id = %info.job_id, file = %job.files[info.idx].name, "✅ Файл допечатан, ждём подтверждения клиента"); info!(job_id = %info.job_id, file = %job.files[info.idx].name,
"✅ Печать завершена, ждём подтверждения");
}
}); });
} }
@ -487,11 +541,13 @@ fn normalize_format(format: &str) -> String {
} }
} }
fn run_lp(path: &Path, settings: Option<&PrintSettings>) -> Result<String, String> { fn run_lp(path: &Path, settings: Option<&PrintSettings>, duplex_pass: Option<&str>) -> Result<String, String> {
let lp = cups_bin("lp"); let lp = cups_bin("lp");
let mut cmd = Command::new(&lp); let mut cmd = Command::new(&lp);
if let Some(s) = settings { if let Some(s) = settings {
cmd.arg("-n").arg(s.copies.to_string()); cmd.arg("-n").arg(s.copies.to_string());
if s.pages != "all" && !s.pages.is_empty() { if s.pages != "all" && !s.pages.is_empty() {
cmd.arg("-P").arg(&s.pages); cmd.arg("-P").arg(&s.pages);
} }
@ -502,40 +558,42 @@ fn run_lp(path: &Path, settings: Option<&PrintSettings>) -> Result<String, Strin
if !fmt.is_empty() { if !fmt.is_empty() {
cmd.arg("-o").arg(format!("PageSize={}", fmt)); cmd.arg("-o").arg(format!("PageSize={}", fmt));
} }
if s.sides.as_deref() == Some("Двусторонняя") {
// Аппаратный дуплекс только если НЕ ручная двухпроходная печать
if s.sides.as_deref() == Some("Двусторонняя") && duplex_pass.is_none() {
cmd.arg("-o").arg("sides=two-sided-long-edge"); cmd.arg("-o").arg("sides=two-sided-long-edge");
} }
match s.dpi.as_deref() {
Some(d) if !d.is_empty() && d != "Авто" => { if let Some(d) = s.dpi.as_deref() {
if !d.is_empty() && d != "Авто" {
cmd.arg("-o").arg(format!("printer-resolution={}dpi", d)); cmd.arg("-o").arg(format!("printer-resolution={}dpi", d));
} }
}
}
// Ручной дуплекс: разбивка на чёт/нечёт
match duplex_pass {
Some("odd") => {
cmd.arg("-o").arg("page-set=odd");
}
Some("even") => {
cmd.arg("-o").arg("page-set=even");
cmd.arg("-o").arg("outputorder=reverse");
}
_ => {} _ => {}
} }
}
cmd.arg(path); cmd.arg(path);
let args: Vec<String> = cmd let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().to_string()).collect();
.get_args() info!(command = format!("{} {}", lp.display(), args.join(" ")), "🖨️ Команда печати");
.map(|a| a.to_string_lossy().to_string())
.collect();
info!(
command = format!("{} {}", lp.display(), args.join(" ")),
"🖨️ Выполнение команды печати"
);
let out = cmd let out = cmd.output().map_err(|e| format!("Не удалось запустить lp: {}", e))?;
.output()
.map_err(|e| format!("Не удалось запустить lp: {}", e))?;
if !out.status.success() { if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
} }
// stdout: "request id is PRINTER-123 (1 file(s))"
let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let stdout = String::from_utf8_lossy(&out.stdout).to_string();
Ok(stdout Ok(stdout.split_whitespace().nth(3).unwrap_or("").to_string())
.split_whitespace()
.nth(3)
.unwrap_or("")
.to_string())
} }
fn lpstat_raw(which: &str) -> Option<String> { fn lpstat_raw(which: &str) -> Option<String> {