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 {
Json(serde_json::json!({
"version": "1.0.1",
"version": "1.0.2",
"timestamp": chrono::Utc::now().timestamp()
}))
}
@ -51,6 +51,7 @@ struct PrintSettings {
enum FileStatus {
Queued,
Printing,
AwaitingFlip,
Awaiting,
Done,
Error,
@ -62,6 +63,7 @@ enum FileStatus {
enum JobPhase {
WaitingStart,
Printing,
AwaitingFlip,
AwaitingPickup,
Finished,
Cancelled,
@ -73,6 +75,9 @@ struct JobFileView {
status: FileStatus,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
/// true → нечётное кол-во страниц, нужно убрать верхний лист перед 2-м проходом
#[serde(default)]
remove_top_sheet: bool,
}
#[derive(Clone, Debug, Serialize)]
@ -103,17 +108,29 @@ impl Job {
JobView {
job_id: self.id.clone(),
phase: self.phase.clone(),
files: self
.files
.iter()
.map(|f| JobFileView {
files: self.files.iter().map(|f| {
JobFileView {
name: f.name.clone(),
status: f.status.clone(),
error: f.error.clone(),
})
.collect(),
remove_top_sheet: Self::should_remove_top_sheet(&f.settings),
}
}).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
}
}
#[derive(Clone)]
@ -122,6 +139,7 @@ struct SpawnInfo {
idx: usize,
path: PathBuf,
settings: Option<PrintSettings>,
is_second_pass: bool,
}
type JobStore = Arc<Mutex<HashMap<String, Job>>>;
@ -279,30 +297,48 @@ async fn job_advance(
) -> Result<Json<JobView>, (StatusCode, String)> {
let (view, spawn_info) = {
let mut m = store.lock().unwrap();
let job = m
.get_mut(&job_id)
let job = m.get_mut(&job_id)
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Задание {} не найдено", job_id)))?;
let si = match job.phase {
JobPhase::WaitingStart => {
info!(job_id = %job_id, "▶️ Клиент дал старт: печать первого файла");
info!(job_id = %job_id, "▶️ Старт первого файла");
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() {
Some(c) => {
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)
}
None => {
job.phase = JobPhase::Finished;
None
}
None => { job.phase = JobPhase::Finished; None }
},
// printing / finished / cancelled — идемпотентно игнорируем
_ => None,
};
(job.view(), si)
};
if let Some(si) = spawn_info {
spawn_print_task(store.clone(), si);
}
@ -362,6 +398,7 @@ fn start_printing_locked(job: &mut Job, idx: usize) -> SpawnInfo {
idx,
path: job.files[idx].path.to_path_buf(),
settings: job.files[idx].settings.clone(),
is_second_pass: false,
}
}
@ -380,57 +417,74 @@ fn begin_next_locked(job: &mut Job, from: usize) -> Option<SpawnInfo> {
fn spawn_print_task(store: JobStore, info: SpawnInfo) {
tokio::spawn(async move {
let path = info.path.clone();
let path = info.path.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
let spool =
tokio::task::spawn_blocking(move || run_lp(&path, settings.as_ref())).await;
let p = path.clone();
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 {
Ok(Ok(id)) => id,
Ok(Err(e)) => return mark_error_and_continue(store, info, e).await,
Err(e) => {
return mark_error_and_continue(store, info, format!("spawn: {}", e)).await
}
Err(e) => return mark_error_and_continue(store, info, format!("spawn: {}", e)).await,
};
// 2) Ждём физического завершения печати
// 2) Ждём физического завершения
if !cups_id.is_empty() {
{
let mut m = store.lock().unwrap();
match m.get_mut(&info.job_id) {
Some(job) if job.phase != JobPhase::Cancelled => {
job.cups_id = Some(cups_id.clone())
}
_ => return,
}
if let Some(job) = m.get_mut(&info.job_id) {
if job.phase != JobPhase::Cancelled {
job.cups_id = Some(cups_id.clone());
} else { return; }
} else { return; }
}
let cid = cups_id.clone();
let wait = tokio::task::spawn_blocking(move || wait_for_cups(&cid)).await;
match wait {
Ok(Ok(())) => {}
Ok(Err(e)) => return mark_error_and_continue(store, info, e).await,
Err(e) => {
return mark_error_and_continue(store, info, format!("spawn: {}", e)).await
}
Err(e) => return mark_error_and_continue(store, info, format!("spawn: {}", e)).await,
}
}
// 3) Допечатано → ждём подтверждения пользователя
// 3) Обновляем статус
let mut m = store.lock().unwrap();
let job = match m.get_mut(&info.job_id) {
Some(j) => j,
None => return,
};
if job.phase == JobPhase::Cancelled
|| job.files.get(info.idx).map(|f| f.status.clone()) != Some(FileStatus::Printing)
{
return;
let job = match m.get_mut(&info.job_id) { 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 is_duplex && !info.is_second_pass {
// Первый проход завершён → ждём, пока пользователь переложит бумагу
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.phase = JobPhase::AwaitingPickup;
job.cups_id = None;
info!(job_id = %info.job_id, file = %job.files[info.idx].name,
"✅ Печать завершена, ждём подтверждения");
}
job.files[info.idx].status = FileStatus::Awaiting;
job.phase = JobPhase::AwaitingPickup;
job.cups_id = None;
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 mut cmd = Command::new(&lp);
if let Some(s) = settings {
cmd.arg("-n").arg(s.copies.to_string());
if s.pages != "all" && !s.pages.is_empty() {
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() {
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");
}
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));
}
_ => {}
}
}
// Ручной дуплекс: разбивка на чёт/нечёт
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);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().to_string())
.collect();
info!(
command = format!("{} {}", lp.display(), args.join(" ")),
"🖨️ Выполнение команды печати"
);
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().to_string()).collect();
info!(command = format!("{} {}", lp.display(), args.join(" ")), "🖨️ Команда печати");
let out = cmd
.output()
.map_err(|e| format!("Не удалось запустить lp: {}", e))?;
let out = cmd.output().map_err(|e| format!("Не удалось запустить lp: {}", e))?;
if !out.status.success() {
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();
Ok(stdout
.split_whitespace()
.nth(3)
.unwrap_or("")
.to_string())
Ok(stdout.split_whitespace().nth(3).unwrap_or("").to_string())
}
fn lpstat_raw(which: &str) -> Option<String> {