diff --git a/backend/alembic/versions/32ac7c6e19bb_app_settings_table.py b/backend/alembic/versions/32ac7c6e19bb_app_settings_table.py new file mode 100644 index 0000000..d321355 --- /dev/null +++ b/backend/alembic/versions/32ac7c6e19bb_app_settings_table.py @@ -0,0 +1,36 @@ +"""app settings table + +Revision ID: 32ac7c6e19bb +Revises: 09f0fefd5d6a +Create Date: 2026-09-19 21:19:05.312601 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '32ac7c6e19bb' +down_revision: str | Sequence[str] | None = '09f0fefd5d6a' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('app_settings', + sa.Column('key', sa.String(length=100), nullable=False), + sa.Column('value', sa.String(length=200), nullable=False), + sa.PrimaryKeyConstraint('key') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('app_settings') + # ### end Alembic commands ### diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py new file mode 100644 index 0000000..5450752 --- /dev/null +++ b/backend/app/api/settings.py @@ -0,0 +1,33 @@ +"""API глобальных настроек (ТЗ: валюта бюджетов — общая, выбирается один раз).""" + +from typing import cast + +from fastapi import APIRouter + +from app.dependencies import DbDep, UserDep +from app.models import AppSetting +from app.schemas import CURRENCIES, AppSettingsOut, AppSettingsUpdate + +router = APIRouter(prefix="/api/settings", tags=["settings"]) + +CURRENCY_KEY = "currency" +DEFAULT_CURRENCY = "UAH" + + +@router.get("", response_model=AppSettingsOut) +async def get_settings(db: DbDep, user: UserDep) -> AppSettingsOut: + row = cast(AppSetting | None, db.get(AppSetting, CURRENCY_KEY)) + currency = row.value if row is not None and row.value in CURRENCIES else DEFAULT_CURRENCY + return AppSettingsOut(currency=currency) + + +@router.put("", response_model=AppSettingsOut) +async def update_settings(schema: AppSettingsUpdate, db: DbDep, user: UserDep) -> AppSettingsOut: + row = cast(AppSetting | None, db.get(AppSetting, CURRENCY_KEY)) + if row is None: + row = AppSetting(key=CURRENCY_KEY, value=schema.currency) + db.add(row) + else: + row.value = schema.currency + db.flush() + return AppSettingsOut(currency=schema.currency) diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 4a2211d..4e8d3fd 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -9,7 +9,7 @@ from app.models import Project, Tag, Task, utcnow from app.schemas import ApproveIn, SuggestIn, TaskCreate, TaskOut, TaskUpdate from app.services.detailing import apply_proposal, detail_task -from app.services.predict import pick_options, predict_minutes +from app.services.options import pick_options router = APIRouter(prefix="/api/tasks", tags=["tasks"]) @@ -156,16 +156,6 @@ return task -@router.post("/{task_id}/predict", response_model=TaskOut) -async def predict_task(task_id: int, db: DbDep, user: UserDep) -> Task: - """Пересчитать прогноз длительности по истории похожих завершённых задач.""" - task = _get_task_or_404(db, task_id) - task.estimated_minutes = predict_minutes(db, task) - db.flush() - db.refresh(task) - return task - - @router.post("/suggest", response_model=list[TaskOut]) async def suggest_tasks(schema: SuggestIn, db: DbDep, user: UserDep) -> list[Task]: """Режим «3 варианта»: до трёх задач под доступное время.""" diff --git a/backend/app/main.py b/backend/app/main.py index 58cdda5..85a9d6d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ from app import mcp_server from app.api import attachments, projects, tags, tasks +from app.api.settings import router as settings_router from app.auth.routes import router as auth_router from app.config import get_settings @@ -60,6 +61,7 @@ app.include_router(projects.router) app.include_router(tags.router) app.include_router(attachments.router) +app.include_router(settings_router) # MCP-сервер для ИИ-агентов (M5) app.mount("/mcp", mcp_server.mcp.streamable_http_app()) diff --git a/backend/app/models.py b/backend/app/models.py index 15c618c..89557bb 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -119,3 +119,15 @@ mime: Mapped[str] = mapped_column(String(100)) size: Mapped[int] = mapped_column(Integer) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + +class AppSetting(Base): + """Глобальные настройки (единственный пользователь — мультитенантности нет). + + Валюта для бюджетов, например: выбирается один раз и применяется всюду. + """ + + __tablename__ = "app_settings" + + key: Mapped[str] = mapped_column(String(100), primary_key=True) + value: Mapped[str] = mapped_column(String(200), nullable=False) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 9fd34ac..fcc393b 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -114,3 +114,14 @@ class TagCreate(BaseModel): name: str = Field(min_length=1, max_length=100) + + +CURRENCIES = ("UAH", "USD", "EUR", "GBP", "PLN") + + +class AppSettingsOut(BaseModel): + currency: str = "UAH" + + +class AppSettingsUpdate(BaseModel): + currency: str = Field(pattern="^(UAH|USD|EUR|GBP|PLN)$") diff --git a/backend/app/services/options.py b/backend/app/services/options.py new file mode 100644 index 0000000..a1c1417 --- /dev/null +++ b/backend/app/services/options.py @@ -0,0 +1,73 @@ +"""Режим «3 варианта» (ТЗ 3.8). + +Попадание в доступное время — нестрогое: оценка может превышать доступное +время до +50% (решение пользователя 2026-09-19). Меньшая задача всегда +подходит. Оценка длительности — только LLM/вручную; задачи без оценки +учитываются как фолбэк. +""" + +from typing import Any + +from sqlalchemy import select + +from app.models import Project, Task + +# допустимое превышение оценки над доступным временем +FIT_TOLERANCE = 0.5 + + +def _fits(estimated_minutes: int, available_minutes: int) -> bool: + return estimated_minutes <= available_minutes * (1 + FIT_TOLERANCE) + + +def pick_options(db: Any, available_minutes: int, limit: int = 3) -> list[Task]: + """«3 варианта» (ТЗ 3.8): задачи, подходящие под доступное время. + + Кандидаты: утверждённые, к выполнению/в работе, проект активен (или без + проекта). Сортировка: приоритет, затем свежесть. Разнообразие: максимум + одна задача на проект; сначала — попадающие в окно ±50%, затем задачи + без оценки (её нельзя отсечь по времени). + """ + stmt = ( + select(Task) + .where(Task.detail_state == "approved", Task.status.in_(("to_do", "in_progress"))) + .order_by(Task.priority.desc().nullslast(), Task.created_at.asc()) + ) + tasks = list(db.scalars(stmt).all()) + + active_projects: set[int | None] = {None} + for p in db.scalars(select(Project)).all(): + if p.relevance_status == "active": + active_projects.add(p.id) + + used_projects: set[int | None] = set() + picked_ids: set[int] = set() + picked: list[Task] = [] + # сначала — попадающие в доступное время (допуск +50%) + for t in tasks: + if len(picked) >= limit: + break + if t.project_id not in active_projects or t.project_id in used_projects: + continue + if t.estimated_minutes is not None and not _fits(t.estimated_minutes, available_minutes): + continue + if t.estimated_minutes is None: + continue # без оценки — второй проход + picked.append(t) + picked_ids.add(t.id) + used_projects.add(t.project_id) + # добираем задачи без оценки длительности (её нельзя отсечь по времени) + if len(picked) < limit: + for t in tasks: + if len(picked) >= limit: + break + if t.estimated_minutes is not None: + continue # с оценкой: либо уложились в первый проход, либо не влезли + if t.id in picked_ids or t.project_id not in active_projects: + continue + if t.project_id in used_projects: + continue + picked.append(t) + picked_ids.add(t.id) + used_projects.add(t.project_id) + return picked[:limit] diff --git a/backend/app/services/predict.py b/backend/app/services/predict.py deleted file mode 100644 index 1b04f34..0000000 --- a/backend/app/services/predict.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Прогнозирование времени и режим «3 варианта» (M4). - -Прогноз — медиана фактической длительности похожих завершённых задач -(похожесть: общий проект или хотя бы один тег). Без истории — None. -LLM-оценка приходит в предложении детализации и применяется при утверждении. -""" - -import statistics -from typing import Any - -from sqlalchemy import select - -from app.models import Project, Task - - -def predict_minutes(db: Any, task: Task) -> int | None: - """Прогноз длительности по истории похожих завершённых задач.""" - candidates: list[int] = [] - task_tag_ids = [t.id for t in task.tags] - done = list( - db.scalars( - select(Task).where(Task.status == "done", Task.actual_minutes.isnot(None)) - ).all() - ) - for other in done: - if other.id == task.id: - continue - similar = other.project_id is not None and other.project_id == task.project_id - if not similar and task_tag_ids and other.tags: - similar = bool(set(t.id for t in other.tags) & set(task_tag_ids)) - if similar: - candidates.append(other.actual_minutes or 0) - candidates = [c for c in candidates if c > 0] - if not candidates: - return None - return int(round(statistics.median(candidates))) - - -def pick_options(db: Any, available_minutes: int, limit: int = 3) -> list[Task]: - """«3 варианта» (ТЗ 3.8): задачи, подходящие под доступное время. - - Кандидаты: утверждённые, к выполнению/в работе, проект активен (или без - проекта). Сортировка: приоритет, затем свежесть. Разнообразие: максимум - одна задача на проект; сначала строго укладывающиеся в время. - """ - stmt = ( - select(Task) - .where(Task.detail_state == "approved", Task.status.in_(("to_do", "in_progress"))) - .order_by(Task.priority.desc().nullslast(), Task.created_at.asc()) - ) - tasks = list(db.scalars(stmt).all()) - - active_projects: set[int | None] = {None} - for p in db.scalars(select(Project)).all(): - if p.relevance_status == "active": - active_projects.add(p.id) - - used_projects: set[int | None] = set() - picked_ids: set[int] = set() - picked: list[Task] = [] - # сначала — строго укладывающиеся в доступное время - for t in tasks: - if len(picked) >= limit: - break - if t.project_id not in active_projects or t.project_id in used_projects: - continue - if t.estimated_minutes is not None and t.estimated_minutes > available_minutes: - continue - picked.append(t) - picked_ids.add(t.id) - used_projects.add(t.project_id) - # добираем задачи без оценки длительности (её нельзя отсечь по времени) - if len(picked) < limit: - for t in tasks: - if len(picked) >= limit: - break - if t.estimated_minutes is not None: - continue # с оценкой: либо уложились в первый проход, либо не влезли - if t.id in picked_ids or t.project_id not in active_projects: - continue - if t.project_id in used_projects: - continue - picked.append(t) - picked_ids.add(t.id) - used_projects.add(t.project_id) - return picked[:limit] diff --git a/backend/tests/test_predict.py b/backend/tests/test_predict.py index 9f628b2..394286d 100644 --- a/backend/tests/test_predict.py +++ b/backend/tests/test_predict.py @@ -1,11 +1,10 @@ -"""Тесты M4: прогноз времени по истории, бюджет, режим «3 варианта».""" +"""Тесты M4: бюджет/оценка затрат, режим «3 варианта», глобальные настройки.""" from typing import Any from fastapi.testclient import TestClient -from app.models import Task -from app.services.predict import pick_options, predict_minutes +from app.services.options import pick_options from tests.conftest import _test_session_factory @@ -13,49 +12,7 @@ return _test_session_factory() -def _db_task(task_id: int) -> Task: - session = _test_session_factory() - task = session.get(Task, task_id) - session.close() - assert task is not None - return task - - -def _mkdone( - client: TestClient, - title: str, - actual: int, - project_id: int | None = None, - tag_id: int | None = None, -) -> None: - tid = client.post("/api/tasks", json={"title": title}).json()["id"] - patch: dict[str, Any] = {"status": "done", "actual_minutes": actual} - if project_id: - patch["project_id"] = project_id - if tag_id: - patch["tag_ids"] = [tag_id] - assert client.patch(f"/api/tasks/{tid}", json=patch).status_code == 200 - - -def test_predict_median_by_project(client: TestClient) -> None: - pid = client.post("/api/projects", json={"name": "Кухня"}).json()["id"] - _mkdone(client, "Помыть посуду", 20, project_id=pid) - _mkdone(client, "Протереть стол", 30, project_id=pid) - _mkdone(client, "Разобрать ящик", 40, project_id=pid) - - fresh = client.post("/api/tasks", json={"title": "Убрать кухню"}).json()["id"] - client.patch(f"/api/tasks/{fresh}", json={"project_id": pid}) - - predicted = predict_minutes(test_session(), _db_task(fresh)) - assert predicted == 30 # медиана 20, 30, 40 - - -def test_predict_none_without_history(client: TestClient) -> None: - fresh = client.post("/api/tasks", json={"title": "Новое"}).json()["id"] - assert predict_minutes(test_session(), _db_task(fresh)) is None - - -def test_predict_endpoint_and_manual_fields(client: TestClient) -> None: +def test_budget_and_actual_fields_manual(client: TestClient) -> None: tid = client.post("/api/tasks", json={"title": "Задача с бюджетом"}).json()["id"] updated = client.patch( f"/api/tasks/{tid}", @@ -65,10 +22,6 @@ assert updated["cost_estimate_money"] == 3500 assert updated["actual_minutes"] == 90 - # нет истории — прогноз не появился, эндпоинт отвечает задачей с null - predicted = client.post(f"/api/tasks/{tid}/predict").json() - assert predicted["estimated_minutes"] is None - def test_pick_options_fit_time_and_diversity(client: TestClient) -> None: p1 = client.post("/api/projects", json={"name": "Дом"}).json()["id"] @@ -87,6 +40,7 @@ approve("Мелочь 30м", 30, p1) approve("Средняя 60м", 60, p2) approve("Большая 300м", 300, None) + approve("Чуть больше 135м", 135, None) # ровно верхняя граница окна 90±50% approve("Без оценки", None, p1) # приостановленный проект исключается paused = client.post("/api/projects", json={"name": "Заморожено"}).json()["id"] @@ -96,15 +50,32 @@ options = pick_options(test_session(), 90) titles = [t.title for t in options] assert len(options) <= 3 - assert "Мелочь 30м" in titles + assert "Мелочь 30м" in titles # короче доступного времени — всегда подходит assert "Средняя 60м" in titles - assert "Большая 300м" not in titles # не влезает в 90 минут + assert "Чуть больше 135м" in titles # верхняя граница ±50% допускается + assert "Большая 300м" not in titles # сильно выходит за окно 90 минут assert "Из замороженного" not in titles # проект неактивен # разнообразие: не более одной задачи на проект projects_used = [t.project_id for t in options] assert len(projects_used) == len(set(projects_used)) +def test_pick_options_no_estimate_fallback(client: TestClient) -> None: + def approve(title: str, minutes: int | None) -> None: + tid = client.post("/api/tasks", json={"title": title}).json()["id"] + if minutes is not None: + client.patch(f"/api/tasks/{tid}", json={"estimated_minutes": minutes}) + client.post(f"/api/tasks/{tid}/approve") + + approve("Не влезает 500м", 500) + approve("Без оценки", None) + + options = pick_options(test_session(), 60) + titles = [t.title for t in options] + assert "Без оценки" in titles # без оценки — учитываем как фолбэк + assert "Не влезает 500м" not in titles + + def test_pick_options_skips_done_and_cancelled(client: TestClient) -> None: tid = client.post("/api/tasks", json={"title": "Завершённая"}).json()["id"] client.patch(f"/api/tasks/{tid}", json={"status": "done"}) @@ -138,3 +109,13 @@ tid = client.post("/api/tasks", json={"title": "Что-то на 45 минут"}).json()["id"] approved = client.post(f"/api/tasks/{tid}/approve", json={"apply_proposal": True}).json() assert approved["estimated_minutes"] == 45 + + +def test_global_currency_settings(client: TestClient) -> None: + # по умолчанию UAH + assert client.get("/api/settings").json() == {"currency": "UAH"} + # смена валюты + assert client.put("/api/settings", json={"currency": "EUR"}).json() == {"currency": "EUR"} + assert client.get("/api/settings").json() == {"currency": "EUR"} + # недопустимая валюта — 422 + assert client.put("/api/settings", json={"currency": "RUB"}).status_code == 422 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9a52649..82c182c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,6 +1,8 @@ diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 288300a..e131616 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -66,6 +66,12 @@ cost_estimate_money?: number | null } +export interface AppSettings { + currency: string +} + +export type AppSettingsUpdate = Partial + export class ApiError extends Error { status: number constructor(status: number, message: string) { @@ -118,7 +124,6 @@ }), redetailTask: (id: number) => request(`/api/tasks/${id}/redetail`, { method: 'POST' }), - predictTask: (id: number) => request(`/api/tasks/${id}/predict`, { method: 'POST' }), suggestTasks: (availableMinutes: number) => request('/api/tasks/suggest', { method: 'POST', @@ -153,4 +158,8 @@ listTags: () => request('/api/tags'), createTag: (name: string) => request('/api/tags', { method: 'POST', body: JSON.stringify({ name }) }), + // settings (глобальные: валюта бюджетов — выбирается один раз) + getSettings: () => request('/api/settings'), + updateSettings: (patch: AppSettingsUpdate) => + request('/api/settings', { method: 'PUT', body: JSON.stringify(patch) }), } \ No newline at end of file diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 0cd0b7a..e911f3c 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -9,6 +9,7 @@ import ListView from './views/ListView.vue' import OptionsView from './views/OptionsView.vue' import ProjectsView from './views/ProjectsView.vue' +import SettingsView from './views/SettingsView.vue' import StackView from './views/StackView.vue' import TreeView from './views/TreeView.vue' @@ -21,6 +22,7 @@ { path: '/list', component: ListView }, { path: '/projects', component: ProjectsView }, { path: '/options', component: OptionsView }, + { path: '/settings', component: SettingsView }, ], }) diff --git a/frontend/src/taskui.ts b/frontend/src/taskui.ts index 9680815..9514437 100644 --- a/frontend/src/taskui.ts +++ b/frontend/src/taskui.ts @@ -49,4 +49,37 @@ if (h && m) return `${h} ч ${m} м` if (h) return `${h} ч` return `${m} м` +} + +// Валюта бюджетов — глобальная настройка, выбирается один раз и применяется всюду +export const CURRENCIES: { value: string; label: string }[] = [ + { value: 'UAH', label: '₴ Гривна (UAH)' }, + { value: 'USD', label: '$ Доллар (USD)' }, + { value: 'EUR', label: '€ Евро (EUR)' }, + { value: 'GBP', label: '£ Фунт (GBP)' }, + { value: 'PLN', label: 'zł Злотый (PLN)' }, +] + +const CURRENCY_SYMBOLS: Record = { + UAH: '₴', + USD: '$', + EUR: '€', + GBP: '£', + PLN: 'zł', +} + +let activeCurrency = 'UAH' + +export function setCurrency(currency: string): void { + activeCurrency = currency +} + +export function currencySymbol(): string { + return CURRENCY_SYMBOLS[activeCurrency] ?? activeCurrency +} + +// «5 000 ₴» — суммы задач; валюта берётся из глобальной настройки +export function formatMoney(amount: number | null): string { + if (amount === null) return '' + return `${amount.toLocaleString('ru-RU')} ${currencySymbol()}` } \ No newline at end of file diff --git a/frontend/src/views/ListView.vue b/frontend/src/views/ListView.vue index 98d3cea..1fbe277 100644 --- a/frontend/src/views/ListView.vue +++ b/frontend/src/views/ListView.vue @@ -1,7 +1,7 @@ + + + + \ No newline at end of file diff --git a/frontend/src/views/StackView.vue b/frontend/src/views/StackView.vue index ff4e45d..61afc2f 100644 --- a/frontend/src/views/StackView.vue +++ b/frontend/src/views/StackView.vue @@ -329,13 +329,13 @@
diff --git a/frontend/src/views/TreeView.vue b/frontend/src/views/TreeView.vue index 979ad7c..aa74d20 100644 --- a/frontend/src/views/TreeView.vue +++ b/frontend/src/views/TreeView.vue @@ -1,7 +1,7 @@