1use crate::entity::member::{
4 MemberEpisodeVo, MemberWorkbenchOverview, MemberWorkbenchRoleCounts, MemberWorkbenchTaskGroup,
5};
6use chrono::NaiveDateTime;
7use sea_orm::{ConnectionTrait, DatabaseConnection, DbBackend, Statement, Value};
8use std::collections::HashMap;
9
10const YURI_ANIMATION_CATEGORY: i32 = 4;
12
13const ROLE_ROWS_CTE_TEMPLATE: &str = r#"
15role_rows AS (
16 SELECT
17 e."Id" AS episode_id,
18 e."mangaId" AS manga_id,
19 r.role_name,
20 r.setup_time,
21 r.update_time,
22 e."mangaEpisode" AS manga_episode,
23 e."mangaEpisodeName" AS manga_episode_name,
24 e."publishLink" AS publish_link,
25 m."mangaTranName" AS manga_tran_name,
26 m."mangaOriName" AS manga_ori_name,
27 m.category AS manga_category,
28 d."translatorUpdateTime" AS translator_update_time,
29 d."proofreaderUpdateTime" AS proofreader_update_time,
30 d."lettererUpdateTime" AS letterer_update_time,
31 d."timerUpdateTime" AS timer_update_time,
32 e."translatorFile" AS translator_file,
33 e."proofreaderFile" AS proofreader_file,
34 e."timerFile" AS timer_file,
35 e.translator_file_oss_id,
36 e.proofreader_file_oss_id,
37 e.timer_file_oss_id
38 FROM mangaepisodetb e
39 INNER JOIN mangaepisodedetail d ON d."episodeId" = e."Id"
40 INNER JOIN mangatb m ON m."Id" = e."mangaId"
41 CROSS JOIN LATERAL (
42 VALUES
43 ('translator'::text, e."translatorId", d."translatorSetupTime", d."translatorUpdateTime"),
44 ('proofreader', e."proofreaderId", d."proofreaderSetupTime", d."proofreaderUpdateTime"),
45 ('letterer', e."lettererId", d."lettererSetupTime", d."lettererUpdateTime"),
46 ('timer', e."timerId", d."timerSetupTime", d."timerUpdateTime"),
47 ('reviewer', e."reviewerId", d."reviewerSetupTime", d."reviewerUpdateTime")
48 ) AS r(role_name, assignee_id, setup_time, update_time)
49 WHERE r.assignee_id = $1
50),
51doing AS (
52 SELECT * FROM role_rows
53 WHERE setup_time IS NOT NULL AND update_time IS NULL
54),
55assigned AS (
56 SELECT * FROM role_rows WHERE setup_time IS NOT NULL
57),
58done AS (
59 SELECT * FROM role_rows WHERE update_time IS NOT NULL
60),
61doing_with_readiness AS (
62 SELECT
63 d.*,
64 (
65 COALESCE(NULLIF(TRIM(d.publish_link), ''), '') <> ''
66 OR d.role_name = 'translator'
67 OR (d.role_name = 'proofreader' AND d.translator_update_time IS NOT NULL)
68 OR (
69 d.role_name = 'letterer'
70 AND d.proofreader_update_time IS NOT NULL
71 AND (
72 d.manga_category IS NULL
73 OR d.manga_category <> {YURI_ANIMATION_CATEGORY}
74 )
75 )
76 OR (
77 d.role_name = 'timer'
78 AND d.proofreader_update_time IS NOT NULL
79 AND d.manga_category = {YURI_ANIMATION_CATEGORY}
80 )
81 OR (
82 d.role_name = 'reviewer'
83 AND (
84 (
85 (
86 d.manga_category IS NULL
87 OR d.manga_category <> {YURI_ANIMATION_CATEGORY}
88 )
89 AND d.letterer_update_time IS NOT NULL
90 )
91 OR (
92 d.manga_category = {YURI_ANIMATION_CATEGORY}
93 AND d.timer_update_time IS NOT NULL
94 )
95 )
96 )
97 ) AS upstream_ready
98 FROM doing d
99)
100"#;
101
102fn role_rows_cte() -> String {
104 ROLE_ROWS_CTE_TEMPLATE.replace(
105 "{YURI_ANIMATION_CATEGORY}",
106 &YURI_ANIMATION_CATEGORY.to_string(),
107 )
108}
109
110#[derive(Debug, Clone)]
112pub struct WorkbenchTaskRow {
113 pub episode_id: i32,
115 pub manga_id: i32,
117 pub sort_time: NaiveDateTime,
119}
120
121pub async fn get_workbench_overview(
123 db: &DatabaseConnection,
124 member_id: i32,
125 stationed_count: i32,
126) -> crate::error::ApiResult<MemberWorkbenchOverview> {
127 let role_rows_cte = role_rows_cte();
128 let sql = format!(
129 r#"WITH {role_rows_cte},
130active_episode_state AS (
131 SELECT episode_id, BOOL_OR(upstream_ready) AS has_ready
132 FROM doing_with_readiness
133 GROUP BY episode_id
134),
135ready_doing AS (
136 SELECT * FROM doing_with_readiness WHERE upstream_ready
137),
138plan_doing AS (
139 SELECT * FROM doing_with_readiness WHERE NOT upstream_ready
140)
141SELECT
142 (SELECT COUNT(DISTINCT episode_id)::int FROM ready_doing) AS pending,
143 (SELECT COUNT(DISTINCT episode_id)::int FROM assigned) AS working,
144 (SELECT COUNT(DISTINCT episode_id)::int FROM done) AS completed,
145 (SELECT COUNT(*)::int FROM active_episode_state WHERE NOT has_ready) AS plan_count,
146 (SELECT COUNT(*)::int FROM active_episode_state WHERE has_ready) AS doing_count,
147 (SELECT COUNT(DISTINCT episode_id)::int FROM ready_doing
148 WHERE setup_time < (NOW() AT TIME ZONE 'UTC') - INTERVAL '60 days') AS overdue,
149 (SELECT COUNT(DISTINCT d.episode_id)::int FROM ready_doing d
150 WHERE COALESCE(d.translator_file, '') <> ''
151 OR COALESCE(d.proofreader_file, '') <> ''
152 OR COALESCE(d.timer_file, '') <> ''
153 OR d.translator_file_oss_id IS NOT NULL
154 OR d.proofreader_file_oss_id IS NOT NULL
155 OR d.timer_file_oss_id IS NOT NULL) AS downloadable,
156 (SELECT COUNT(DISTINCT episode_id)::int FROM ready_doing WHERE role_name = 'translator') AS cnt_translator,
157 (SELECT COUNT(DISTINCT episode_id)::int FROM ready_doing WHERE role_name = 'proofreader') AS cnt_proofreader,
158 (SELECT COUNT(DISTINCT episode_id)::int FROM ready_doing WHERE role_name = 'letterer') AS cnt_letterer,
159 (SELECT COUNT(DISTINCT episode_id)::int FROM ready_doing WHERE role_name = 'timer') AS cnt_timer,
160 (SELECT COUNT(DISTINCT episode_id)::int FROM ready_doing WHERE role_name = 'reviewer') AS cnt_reviewer,
161 (SELECT COUNT(DISTINCT episode_id)::int FROM plan_doing WHERE role_name = 'translator') AS plan_cnt_translator,
162 (SELECT COUNT(DISTINCT episode_id)::int FROM plan_doing WHERE role_name = 'proofreader') AS plan_cnt_proofreader,
163 (SELECT COUNT(DISTINCT episode_id)::int FROM plan_doing WHERE role_name = 'letterer') AS plan_cnt_letterer,
164 (SELECT COUNT(DISTINCT episode_id)::int FROM plan_doing WHERE role_name = 'timer') AS plan_cnt_timer,
165 (SELECT COUNT(DISTINCT episode_id)::int FROM plan_doing WHERE role_name = 'reviewer') AS plan_cnt_reviewer
166"#
167 );
168 let row = db
169 .query_one(Statement::from_sql_and_values(
170 DbBackend::Postgres,
171 &sql,
172 [Value::from(member_id)],
173 ))
174 .await?
175 .ok_or_else(|| crate::error::AppError::business("工作台总览查询失败喵"))?;
176
177 let pending: i32 = row.try_get("", "pending")?;
178 Ok(MemberWorkbenchOverview {
179 pending,
180 working: row.try_get("", "working")?,
181 overdue: row.try_get("", "overdue")?,
182 downloadable: row.try_get("", "downloadable")?,
183 completed: row.try_get("", "completed")?,
184 stationed: stationed_count,
185 all_count: pending,
186 plan_count: row.try_get("", "plan_count")?,
187 doing_count: row.try_get("", "doing_count")?,
188 role_counts: MemberWorkbenchRoleCounts {
189 translator: row.try_get("", "cnt_translator")?,
190 proofreader: row.try_get("", "cnt_proofreader")?,
191 letterer: row.try_get("", "cnt_letterer")?,
192 timer: row.try_get("", "cnt_timer")?,
193 reviewer: row.try_get("", "cnt_reviewer")?,
194 },
195 plan_role_counts: MemberWorkbenchRoleCounts {
196 translator: row.try_get("", "plan_cnt_translator")?,
197 proofreader: row.try_get("", "plan_cnt_proofreader")?,
198 letterer: row.try_get("", "plan_cnt_letterer")?,
199 timer: row.try_get("", "plan_cnt_timer")?,
200 reviewer: row.try_get("", "plan_cnt_reviewer")?,
201 },
202 })
203}
204
205pub fn normalize_workbench_tab(tab: Option<&str>) -> &'static str {
207 match tab.unwrap_or("all") {
208 "translator" => "translator",
209 "proofreader" => "proofreader",
210 "letterer" => "letterer",
211 "timer" => "timer",
212 "reviewer" => "reviewer",
213 _ => "all",
214 }
215}
216
217pub fn normalize_workbench_view(view: Option<&str>) -> &'static str {
219 match view.unwrap_or("all") {
220 "plan" => "plan",
221 "doing" => "doing",
222 _ => "all",
223 }
224}
225
226pub async fn count_workbench_tasks(
228 db: &DatabaseConnection,
229 member_id: i32,
230 tab: &str,
231 view: &str,
232 keyword: Option<&str>,
233) -> crate::error::ApiResult<i64> {
234 let kw = keyword.unwrap_or("").trim();
235 let kw_pattern = if kw.is_empty() {
236 None
237 } else {
238 Some(format!("%{kw}%"))
239 };
240 let role_rows_cte = role_rows_cte();
241 let sql = format!(
242 r#"WITH {role_rows_cte},
243 filtered AS (
244 SELECT d.episode_id, d.manga_id, BOOL_OR(d.upstream_ready) AS has_ready
245 FROM doing_with_readiness d
246 WHERE ($2 = 'all' OR d.role_name = $2)
247 AND (
248 $3 IS NULL OR
249 d.manga_tran_name ILIKE $3 OR
250 d.manga_ori_name ILIKE $3 OR
251 d.manga_episode ILIKE $3 OR
252 COALESCE(d.manga_episode_name, '') ILIKE $3
253 )
254 GROUP BY d.episode_id, d.manga_id
255 ),
256 selected_mangas AS (
257 SELECT DISTINCT manga_id
258 FROM filtered
259 WHERE $4 = 'all'
260 OR ($4 = 'doing' AND has_ready)
261 OR ($4 = 'plan' AND NOT has_ready)
262 )
263 SELECT COUNT(*)::bigint AS cnt FROM selected_mangas"#
264 );
265 let row = db
266 .query_one(Statement::from_sql_and_values(
267 DbBackend::Postgres,
268 &sql,
269 [
270 Value::from(member_id),
271 Value::from(tab),
272 kw_pattern.into(),
273 Value::from(view),
274 ],
275 ))
276 .await?
277 .ok_or_else(|| crate::error::AppError::business("工作台任务计数失败喵"))?;
278 Ok(row.try_get("", "cnt")?)
279}
280
281pub async fn page_workbench_manga_ids(
283 db: &DatabaseConnection,
284 member_id: i32,
285 tab: &str,
286 view: &str,
287 keyword: Option<&str>,
288 page: i32,
289 page_size: i32,
290) -> crate::error::ApiResult<Vec<i32>> {
291 let page = page.max(1);
292 let page_size = page_size.clamp(1, 100);
293 let offset = ((page - 1) * page_size) as i64;
294 let kw = keyword.unwrap_or("").trim();
295 let kw_pattern = if kw.is_empty() {
296 None
297 } else {
298 Some(format!("%{kw}%"))
299 };
300 let role_rows_cte = role_rows_cte();
301 let sql = format!(
302 r#"WITH {role_rows_cte},
303 filtered AS (
304 SELECT
305 d.episode_id,
306 d.manga_id,
307 MAX(d.setup_time) AS sort_time,
308 BOOL_OR(d.upstream_ready) AS has_ready
309 FROM doing_with_readiness d
310 WHERE ($2 = 'all' OR d.role_name = $2)
311 AND (
312 $3 IS NULL OR
313 d.manga_tran_name ILIKE $3 OR
314 d.manga_ori_name ILIKE $3 OR
315 d.manga_episode ILIKE $3 OR
316 COALESCE(d.manga_episode_name, '') ILIKE $3
317 )
318 GROUP BY d.episode_id, d.manga_id
319 ),
320 selected_episodes AS (
321 SELECT episode_id, manga_id, sort_time
322 FROM filtered
323 WHERE $4 = 'all'
324 OR ($4 = 'doing' AND has_ready)
325 OR ($4 = 'plan' AND NOT has_ready)
326 ),
327 selected_mangas AS (
328 SELECT manga_id, MAX(sort_time) AS manga_sort_time
329 FROM selected_episodes
330 GROUP BY manga_id
331 )
332 SELECT manga_id
333 FROM selected_mangas
334 ORDER BY manga_sort_time DESC NULLS LAST, manga_id DESC
335 LIMIT $5 OFFSET $6"#
336 );
337 let rows = db
338 .query_all(Statement::from_sql_and_values(
339 DbBackend::Postgres,
340 &sql,
341 [
342 Value::from(member_id),
343 Value::from(tab),
344 kw_pattern.into(),
345 Value::from(view),
346 Value::from(page_size),
347 Value::from(offset),
348 ],
349 ))
350 .await?;
351 rows.into_iter()
352 .map(|row| row.try_get("", "manga_id").map_err(Into::into))
353 .collect()
354}
355
356pub async fn list_workbench_task_rows_by_mangas(
358 db: &DatabaseConnection,
359 member_id: i32,
360 tab: &str,
361 view: &str,
362 keyword: Option<&str>,
363 manga_ids: &[i32],
364) -> crate::error::ApiResult<Vec<WorkbenchTaskRow>> {
365 if manga_ids.is_empty() {
366 return Ok(vec![]);
367 }
368 let kw = keyword.unwrap_or("").trim();
369 let kw_pattern = if kw.is_empty() {
370 None
371 } else {
372 Some(format!("%{kw}%"))
373 };
374 let placeholders: Vec<String> = (0..manga_ids.len())
375 .map(|i| format!("${}", i + 5))
376 .collect();
377 let manga_in = placeholders.join(", ");
378 let manga_order = format!("ARRAY[{}]::int[]", manga_in);
379 let role_rows_cte = role_rows_cte();
380 let sql = format!(
381 r#"WITH {role_rows_cte},
382filtered AS (
383 SELECT
384 d.episode_id,
385 d.manga_id,
386 MAX(d.setup_time) AS sort_time,
387 BOOL_OR(d.upstream_ready) AS has_ready
388 FROM doing_with_readiness d
389 WHERE ($2 = 'all' OR d.role_name = $2)
390 AND (
391 $3 IS NULL OR
392 d.manga_tran_name ILIKE $3 OR
393 d.manga_ori_name ILIKE $3 OR
394 d.manga_episode ILIKE $3 OR
395 COALESCE(d.manga_episode_name, '') ILIKE $3
396 )
397 AND d.manga_id IN ({manga_in})
398 GROUP BY d.episode_id, d.manga_id
399),
400selected AS (
401 SELECT episode_id, manga_id, sort_time
402 FROM filtered
403 WHERE $4 = 'all'
404 OR ($4 = 'doing' AND has_ready)
405 OR ($4 = 'plan' AND NOT has_ready)
406)
407SELECT episode_id, manga_id, sort_time
408FROM selected
409ORDER BY array_position({manga_order}, manga_id), sort_time DESC NULLS LAST, episode_id DESC"#
410 );
411 let mut values = vec![
412 Value::from(member_id),
413 Value::from(tab),
414 kw_pattern.into(),
415 Value::from(view),
416 ];
417 values.extend(manga_ids.iter().copied().map(Value::from));
418 let rows = db
419 .query_all(Statement::from_sql_and_values(
420 DbBackend::Postgres,
421 &sql,
422 values,
423 ))
424 .await?;
425 rows.into_iter()
426 .map(|row| {
427 Ok(WorkbenchTaskRow {
428 episode_id: row.try_get("", "episode_id")?,
429 manga_id: row.try_get("", "manga_id")?,
430 sort_time: row.try_get("", "sort_time")?,
431 })
432 })
433 .collect()
434}
435
436pub async fn assemble_workbench_task_groups(
438 db: &DatabaseConnection,
439 manga_ids: Vec<i32>,
440 ordered: Vec<WorkbenchTaskRow>,
441) -> crate::error::ApiResult<Vec<MemberWorkbenchTaskGroup>> {
442 if ordered.is_empty() {
443 return Ok(vec![]);
444 }
445 let ids: Vec<i32> = ordered.iter().map(|row| row.episode_id).collect();
446 let episodes = super::member_episode_assemble::load_episode_models_by_ids(db, &ids).await?;
447 let mut vos = super::member_episode_assemble::assemble_member_episode_vos(db, episodes).await?;
448 let time_map: HashMap<i32, NaiveDateTime> = ordered
449 .iter()
450 .map(|row| (row.episode_id, row.sort_time))
451 .collect();
452 let manga_map: HashMap<i32, i32> = ordered
453 .iter()
454 .map(|row| (row.episode_id, row.manga_id))
455 .collect();
456 let order: HashMap<i32, usize> = ids.iter().enumerate().map(|(i, id)| (*id, i)).collect();
457 for vo in &mut vos {
458 if let Some(t) = time_map.get(&vo.id) {
459 vo.workbench_time = Some(crate::utils::legacy_time::naive_db_to_api_utc(*t));
460 }
461 }
462 vos.sort_by_key(|vo| order.get(&vo.id).copied().unwrap_or(usize::MAX));
463 let mut grouped: HashMap<i32, Vec<MemberEpisodeVo>> = HashMap::new();
464 for vo in vos {
465 let manga_id = manga_map.get(&vo.id).copied().unwrap_or(vo.manga_id);
466 grouped.entry(manga_id).or_default().push(vo);
467 }
468 let groups = manga_ids
469 .into_iter()
470 .filter_map(|manga_id| {
471 let tasks = grouped.remove(&manga_id)?;
472 let manga_name = tasks
473 .first()
474 .and_then(|task| task.manga_name.clone())
475 .unwrap_or_else(|| format!("漫画#{manga_id}"));
476 Some(MemberWorkbenchTaskGroup {
477 manga_id,
478 manga_name,
479 tasks,
480 })
481 })
482 .collect();
483 Ok(groups)
484}