Skip to main content

tdm_server_rust/repository/
member_workbench_assemble.rs

1//! 个人工作台 SQL 聚合(总览统计 + 分页在做任务,避免前端拉全量话数)
2
3use 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
10/// 百合动画分类 ID,动画工序第三棒使用时轴。
11const YURI_ANIMATION_CATEGORY: i32 = 4;
12
13/// 组员在各岗位的接稿/交稿行模板(UNION LATERAL 展开)。
14const 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
102/// 组员在各岗位的接稿/交稿行(UNION LATERAL 展开)。
103fn role_rows_cte() -> String {
104    ROLE_ROWS_CTE_TEMPLATE.replace(
105        "{YURI_ANIMATION_CATEGORY}",
106        &YURI_ANIMATION_CATEGORY.to_string(),
107    )
108}
109
110/// 工作台任务分页中间行。
111#[derive(Debug, Clone)]
112pub struct WorkbenchTaskRow {
113    /// 话数 ID
114    pub episode_id: i32,
115    /// 漫画 ID
116    pub manga_id: i32,
117    /// 当前组员在该话数上的最新接稿时间
118    pub sort_time: NaiveDateTime,
119}
120
121/// 查询工作台总览统计
122pub 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
205/// 规范化 Tab:非法值视为 all
206pub 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
217/// 规范化 View:非法值视为 all
218pub 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
226/// 统计工作台匹配漫画组数(Tab + View + 关键词)
227pub 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
281/// 分页查询工作台漫画组 ID(按组内最新接稿时间降序)
282pub 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
356/// 查询指定漫画组内的工作台任务行。
357pub 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
436/// 按漫画组顺序组装工作台任务 VO,并填充 workbench_time
437pub 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}