Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion projects/core/src/lib/services/api/api.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { TestBed } from "@angular/core/testing";

import { ApiService } from "./api.service";
import { HttpClientTestingModule } from "@angular/common/http/testing";
import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
import { API_URL } from "../../providers";

describe("ApiService", () => {
Expand All @@ -20,4 +20,21 @@ describe("ApiService", () => {
it("should be created", () => {
expect(service).toBeTruthy();
});

it("posts a full file selection and returns the original XLSX Blob", () => {
const body = { selection: { mode: "all", excludedIds: [7] } };
const blob = new Blob(["xlsx"], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
let result: Blob | undefined;
service.postFile("/attention/export/", body).subscribe(value => (result = value));
const http = TestBed.inject(HttpTestingController);
const request = http.expectOne("/attention/export/");
expect(request.request.method).toBe("POST");
expect(request.request.body).toEqual(body);
expect(request.request.responseType).toBe("blob");
request.flush(blob);
expect(result).toBe(blob);
http.verify();
});
});
4 changes: 4 additions & 0 deletions projects/core/src/lib/services/api/api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ export class ApiService {
.pipe(retry(exponentialBackoff(this.RETRY_COUNT)), first()) as Observable<Blob>;
}

postFile(path: string, body: object): Observable<Blob> {
return this.http.post(this.apiUrl + path, body, { responseType: "blob" }).pipe(first());
}

/**
* Выполняет PUT запрос к API (полное обновление ресурса)
* @param path - Относительный путь к ресурсу
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export class ExportFileService {

private readonly apiService = inject(ApiService);

exportProgram(programId: number) {
return this.apiService.getFile(`/programs/${programId}/project-analytics/export/`);
}

exportProgramRates(programId: number) {
return this.apiService.getFile(
`${this.EXPORT_PROGRAM_FILE_URL}${programId}${this.EXPORT_PROGRAM_RATES_URL}`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/** @format */
import { signal } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { ApiService, LoggerService } from "@corelib";
import { Subject } from "rxjs";
import { ProgramDetailMainUIInfoService } from "../../program/facades/detail/ui/program-detail-main-ui-info.service";
import { ExportFileInfoService } from "./export-file-info.service";
import { saveFile } from "@utils/export-file";

vi.mock("@utils/export-file", () => ({ saveFile: vi.fn() }));

describe("Consolidated program download", () => {
const program = signal<{ id: number; name: string } | null>({ id: 12, name: "Программа" });
let response: Subject<Blob>;
let state: ExportFileInfoService;
const api = { getFile: vi.fn() };
beforeEach(() => {
vi.clearAllMocks();
program.set({ id: 12, name: "Программа" });
response = new Subject<Blob>();
api.getFile.mockReturnValue(response);
TestBed.configureTestingModule({
providers: [
ExportFileInfoService,
{ provide: ApiService, useValue: api },
{ provide: LoggerService, useValue: { error: vi.fn() } },
{ provide: ProgramDetailMainUIInfoService, useValue: { program } },
],
});
state = TestBed.inject(ExportFileInfoService);
});

it("requests the program scoped workbook once and preserves the requested file name", () => {
state.downloadProgram();
state.downloadProgram();
expect(api.getFile).toHaveBeenCalledExactlyOnceWith("/programs/12/project-analytics/export/");
expect(state.loadingExports$().status).toBe("loading");
program.set({ id: 13, name: "Другая программа" });
const blob = new Blob(["workbook"]);
response.next(blob);
response.complete();
expect(saveFile).toHaveBeenCalledExactlyOnceWith(blob, "program", "Программа");
expect(state.loadingExports$().status).toBe("success");
});

it("allows a retry after a download error", () => {
state.downloadProgram();
response.error(new Error("network"));
expect(state.loadingExports$().status).toBe("failure");
expect(saveFile).not.toHaveBeenCalled();
api.getFile.mockReturnValue(new Subject<Blob>());
state.downloadProgram();
expect(api.getFile).toHaveBeenCalledTimes(2);
});

it("does not request before the program is available", () => {
program.set(null);
state.downloadProgram();
expect(api.getFile).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@ export class ExportFileInfoService {

readonly loadingExports$ = signal<AsyncState<void>>(initial());

downloadProgram(): void {
const program = this.program();
if (!program || this.loadingExports$().status === "loading") return;
this.loadingExports$.set(loading());
this.exportFileService
.exportProgram(program.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: blob => {
saveFile(blob, "program", program.name);
this.loadingExports$.set(success(undefined));
},
error: () => this.loadingExports$.set(failure("program_export_failed")),
});
}

downloadProjects(): void {
this.loadingExports$.set(loading());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
import { computed, DestroyRef, inject, Injectable, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Subject, takeUntil } from "rxjs";
import { saveAs } from "file-saver";
import { ProgramAttentionActionsUseCase } from "@api/program/use-cases/program-attention-actions.use-case";
import {
ProgramAttentionCategory,
ProgramAttentionSelection,
ProgramAttentionPreview,
} from "@domain/program/program-attention-actions.model";
import { ProgramCaseProjectsService } from "./program-case-projects.service";
import { ProgramCaseSelection } from "@domain/program/program-case-analytics.model";
import { ProgramAnalyticsCaseMetrics } from "@domain/program/program-analytics.model";
Expand Down Expand Up @@ -43,6 +50,181 @@ export type AnalyticsDrilldownView =
/** Живёт вместе с одной analytics-модалкой, не сохраняет manager-данные между открытиями. */
@Injectable()
export class ProgramAnalyticsDrilldownService {
private readonly actions = inject(ProgramAttentionActionsUseCase);
private readonly cancelActions = new Subject<void>();
readonly selection = signal<ProgramAttentionSelection>({
mode: "selected",
ids: [],
excludedIds: [],
});
private readonly allSelectionCount = signal(0);
readonly preview = signal<ProgramAttentionPreview | null>(null);
readonly actionPending = signal<"preview" | "send" | "export" | null>(null);
readonly actionError = signal("");
readonly actionSuccess = signal("");
readonly supportsActions = computed(
() => this.isAttentionView() && this.view() !== "projects-not-submitted",
);
readonly selectedCount = computed(() => {
const selection = this.selection();
return selection.mode === "all"
? Math.max(0, this.allSelectionCount() - selection.excludedIds.length)
: selection.ids.length;
});

readonly allFoundSelected = computed(
() => this.selectedCount() > 0 && this.selectedCount() === this.attentionCount(),
);
readonly someFoundSelected = computed(() => this.selectedCount() > 0 && !this.allFoundSelected());

toggleAllFound(): void {
if (this.allFoundSelected()) this.clearSelection();
else this.selectAllFound();
}

isSelected(id: number): boolean {
const selection = this.selection();
return selection.mode === "all"
? !selection.excludedIds.includes(id)
: selection.ids.includes(id);
}

toggleSelection(id: number): void {
if (this.actionPending()) return;
this.preview.set(null);
this.actionError.set("");
this.actionSuccess.set("");
const current = this.selection();
const field = current.mode === "all" ? "excludedIds" : "ids";
const ids = current[field];
this.selection.set({
...current,
[field]: ids.includes(id) ? ids.filter(value => value !== id) : [...ids, id],
});
}

selectAllFound(): void {
if (this.actionPending() || this.attentionPending() || !this.attentionCount()) return;
this.allSelectionCount.set(this.attentionCount());
this.selection.set({ mode: "all", ids: [], excludedIds: [] });
this.preview.set(null);
this.actionError.set("");
this.actionSuccess.set("");
}

clearSelection(): void {
if (this.actionPending()) return;
this.resetActions();
}

backToAttentionList(): void {
if (this.actionPending()) return;
this.preview.set(null);
this.actionError.set("");
}

previewReminder(): void {
const category = this.actionCategory();
if (
this.programId === null ||
!category ||
!this.selectedCount() ||
this.actionPending() ||
this.attentionPending() ||
this.attentionError()
)
return;
this.actionPending.set("preview");
this.actionError.set("");
this.actionSuccess.set("");
this.actions
.preview(this.programId, category, {
search: this.appliedSearch(),
selection: this.selection(),
})
.pipe(takeUntil(this.cancelActions), takeUntilDestroyed(this.destroyRef))
.subscribe(result => {
this.actionPending.set(null);
if (result.ok) this.preview.set(result.value);
else this.setActionError(result.error.kind);
});
}

sendReminder(): void {
const category = this.actionCategory();
const token = this.preview()?.token;
if (this.programId === null || !category || !token || this.actionPending()) return;
this.actionPending.set("send");
this.actionError.set("");
this.actions
.send(this.programId, category, token)
.pipe(takeUntil(this.cancelActions), takeUntilDestroyed(this.destroyRef))
.subscribe(result => {
this.actionPending.set(null);
if (result.ok) {
this.resetActions();
const message = result.value.createdCount
? `Напоминания созданы: ${result.value.createdCount}. Письма поставлены в очередь.`
: "Нет доступных получателей для напоминания.";
this.actionSuccess.set(
`${message}${result.value.skippedCount ? ` Пропущено после повторной проверки: ${result.value.skippedCount}.` : ""}`,
);
} else this.setActionError(result.error.kind);
});
}

exportAttention(): void {
const category = this.actionCategory();
if (
this.programId === null ||
!category ||
this.actionPending() ||
this.attentionPending() ||
this.attentionError() ||
!this.attentionCount()
)
return;
this.actionPending.set("export");
this.actionError.set("");
this.actions
.export(this.programId, category, {
search: this.appliedSearch(),
selection: this.selectedCount()
? this.selection()
: { mode: "all", ids: [], excludedIds: [] },
})
.pipe(takeUntil(this.cancelActions), takeUntilDestroyed(this.destroyRef))
.subscribe(result => {
this.actionPending.set(null);
if (result.ok) saveAs(result.value, `Требует внимания-${category}-${this.programId}.xlsx`);
else this.setActionError(result.error.kind);
});
}

private actionCategory(): ProgramAttentionCategory | null {
return this.open() && this.supportsActions() ? (this.view() as ProgramAttentionCategory) : null;
}

private setActionError(kind: string): void {
this.actionError.set(
kind === "forbidden" || kind === "unauthorized"
? "Нет доступа к действию. Обновите страницу и проверьте права."
: kind === "validation"
? "Выбор или предпросмотр устарел либо превышает 10 000 строк. Уточните поиск и проверьте получателей еще раз."
: "Не удалось выполнить действие. Попробуйте еще раз.",
);
if (kind === "validation") this.preview.set(null);
}

private resetActions(): void {
this.cancelActions.next();
this.selection.set({ mode: "selected", ids: [], excludedIds: [] });
this.allSelectionCount.set(0);
this.preview.set(null);
this.actionPending.set(null);
this.actionError.set("");
this.actionSuccess.set("");
}
readonly cases = inject(ProgramCaseProjectsService);

openCase(
Expand Down Expand Up @@ -140,6 +322,8 @@ export class ProgramAnalyticsDrilldownService {
/** Query остаётся черновиком до Enter; сервер получает trimmed search с первой страницы. */
applyAttentionSearch(): void {
if (!this.open() || !this.isAttentionView()) return;
if (this.actionPending()) return;
this.resetActions();
this.appliedSearch.set(this.searchDraft().trim());
this.attentionOffset.set(0);
this.loadAttention();
Expand Down Expand Up @@ -299,6 +483,7 @@ export class ProgramAnalyticsDrilldownService {

/** Закрытие и смена программы уничтожают весь локальный контекст и активные запросы. */
close(): void {
this.resetActions();
this.cases.reset();
this.cancelAssignments.next();
this.cancelScores.next();
Expand Down
Loading