sdlc-hub/web/src/board/model/fromCsv.ts
2026-10-14 21:49:17 +04:00

52 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { EPICS, KEY_RE, NO_EPIC } from "../constants.ts";
import type { BoardModel, BoardNode, Dicts, Link } from "../types.ts";
import { parseCsv } from "./csv.ts";
import { addLink, addStubs } from "./links.ts";
import { sortableDate } from "./util.ts";
// CSV из фильтра Jira: нужны колонки Ключ проблемы, Тема, Статус, Приоритет, Тип задачи, Метки, Связи Фичи, Плановая дата окончания, Исполнитель, Спринт
// и «Внешняя/Внутренняя ссылка на проблему (ASUPR_…)». У задачи X «Внешняя ссылка (ASUPR_Блокер_Начала)» содержит Y, если X блокирует Y
// (outward-описание «Блокирует начало выполнения»); «Внутренняя ссылка» — если Y блокирует X. До 11.10.2026 связи в Jira стояли
// задом наперёд (ошибка направления в jira_sync.py), и страница читала «Внешняя» как блокеров; 11.10.2026 связи пересозданы, чтение исправлено.
export function fromCsv(text: string, fileName: string, dicts: Dicts): BoardModel {
const rows = parseCsv(text);
if (rows.length < 2) throw new Error("в файле нет строк с задачами");
const head = rows[0].map((h) => h.trim());
const find = (...subs: string[]) => head.findIndex((h) => subs.some((s) => h.toLowerCase().includes(s.toLowerCase())));
const cols = {
key: find("Ключ проблемы", "Ключ задачи", "Issue key", "Ключ"), sum: find("Тема", "Резюме", "Summary"), status: find("Статус", "Status"),
prio: find("Приоритет", "Priority"), type: find("Тип задачи", "Тип запроса", "Issue Type"), epic: find("Связи Фичи"),
due: find("Плановая дата окончания", "Срок исполнения", "Due date"), who: find("Исполнитель", "Assignee"),
};
const labelCols = head.map((h, i) => (/^(Метки|Labels)$/i.test(h) ? i : -1)).filter((i) => i >= 0);
const sprintCols = head.map((h, i) => (/^(Спринт|Sprint)$/i.test(h) ? i : -1)).filter((i) => i >= 0);
if (cols.key < 0 || cols.sum < 0) throw new Error("не нашёл колонки «Ключ проблемы» и «Тема»; в файле: " + head.slice(0, 10).join(", ") + "…");
const linkCols: { i: number; type: string; out: boolean }[] = [];
head.forEach((h, i) => { for (const [t, type] of Object.entries(dicts.linkTypes)) if (h.includes(t)) linkCols.push({ i, type, out: /outward|исход|внешн/.test(h.toLowerCase()) }); });
const problems: string[] = [];
if (!linkCols.length) problems.push("в файле нет колонок связей — в фильтре Jira добавьте колонку «Связанные задачи» или экспортируйте «все поля»");
if (!sprintCols.length) problems.push("в файле нет колонки «Спринт» — выбор спринта недоступен, все задачи считаются выбранными");
if (cols.epic < 0) problems.push("нет колонки «Связи Фичи» — дорожки по эпикам не построить");
const cell = (r: string[], i: number) => (i >= 0 ? r[i] || "" : "").trim();
const isEpic = (r: string[]) => cols.type >= 0 && /^(Фича|Epic|Эпик)$/i.test(cell(r, cols.type));
const epicNames: Record<string, string> = { ...EPICS };
rows.slice(1).forEach((r) => { const k = cell(r, cols.key); if (KEY_RE.test(k) && isEpic(r)) epicNames[k] = cell(r, cols.sum) || k; });
const nodes: BoardNode[] = [], links: Link[] = [], seen = new Set<string>(), sprints: Record<string, number> = {};
for (const r of rows.slice(1)) {
const key = cell(r, cols.key); if (!KEY_RE.test(key) || isEpic(r)) continue;
let kind = cell(r, cols.type);
for (const c of labelCols) { const v = cell(r, c); if (dicts.kindLabels[v]) kind = dicts.kindLabels[v]; }
const epicKey = cell(r, cols.epic);
const sprint = sprintCols.map((c) => cell(r, c)).filter(Boolean).pop() || "";
if (sprint) sprints[sprint] = (sprints[sprint] || 0) + 1;
nodes.push({ key, title: cell(r, cols.sum), kind, prio: cell(r, cols.prio), due: sortableDate(cell(r, cols.due)), status: cell(r, cols.status),
epic: epicKey ? (epicNames[epicKey] || epicKey) : NO_EPIC, sprint, who: cell(r, cols.who), stub: false });
for (const lc of linkCols) {
const other = cell(r, lc.i); if (!KEY_RE.test(other)) continue;
addLink(links, seen, lc.out ? key : other, lc.out ? other : key, lc.type);
}
}
const missing = addStubs(nodes, links);
return { nodes, links, sprints, fileName, problems, hasSprint: sprintCols.length > 0, missing };
}