Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/23
This commit was merged in pull request #23.
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
|
||||
import { IStorageLike, readCachedCompanies, writeCachedCompanies } from './companiesCache';
|
||||
import { ICompany } from './models/ICompany';
|
||||
|
||||
// GET /Dictionary/Companies — справочник компаний, ответ — плоский массив
|
||||
// { Id, Name } (без обёртки, как /Dictionary/Departments).
|
||||
// { Id, Name, Bin } (без обёртки, как /Dictionary/Departments).
|
||||
async function fetchCompanies(context: WebPartContext): Promise<ICompany[]> {
|
||||
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||
const response: HttpClientResponse = await client.get(
|
||||
@@ -17,12 +18,33 @@ async function fetchCompanies(context: WebPartContext): Promise<ICompany[]> {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Справочник в рамках сессии не меняется — грузим один раз и отдаём тот же
|
||||
// промис повторным вызовам (как в departments.service).
|
||||
/** localStorage, если он есть: в некоторых режимах браузера обращение к нему бросает исключение. */
|
||||
function browserStorage(): IStorageLike | undefined {
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCompanies(context: WebPartContext): Promise<ICompany[]> {
|
||||
const storage = browserStorage();
|
||||
const cached = storage ? readCachedCompanies(storage, Date.now()) : undefined;
|
||||
if (cached) return cached;
|
||||
|
||||
const fresh = await fetchCompanies(context);
|
||||
if (storage) writeCachedCompanies(storage, fresh, Date.now());
|
||||
return fresh;
|
||||
}
|
||||
|
||||
// Справочник живёт в localStorage 4 часа (см. companiesCache.ts) — между перезагрузками страницы
|
||||
// к API не ходим. Поверх этого один промис на сессию: параллельные вызовы делят один запрос и не
|
||||
// читают хранилище заново (как в departments.service). Ошибку не кешируем — следующий вызов
|
||||
// попробует ещё раз.
|
||||
let cache: Promise<ICompany[]> | undefined;
|
||||
export function getCompanies(context: WebPartContext): Promise<ICompany[]> {
|
||||
if (!cache) {
|
||||
cache = fetchCompanies(context).catch((e) => {
|
||||
cache = loadCompanies(context).catch((e) => {
|
||||
cache = undefined;
|
||||
throw e;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
COMPANIES_CACHE_KEY,
|
||||
COMPANIES_CACHE_TTL_MS,
|
||||
IStorageLike,
|
||||
readCachedCompanies,
|
||||
writeCachedCompanies
|
||||
} from './companiesCache';
|
||||
import { ICompany } from './models/ICompany';
|
||||
|
||||
class MemoryStorage implements IStorageLike {
|
||||
public readonly data = new Map<string, string>();
|
||||
// eslint-disable-next-line @rushstack/no-new-null
|
||||
public getItem(key: string): string | null {
|
||||
return this.data.has(key) ? (this.data.get(key) as string) : null;
|
||||
}
|
||||
public setItem(key: string, value: string): void {
|
||||
this.data.set(key, value);
|
||||
}
|
||||
public removeItem(key: string): void {
|
||||
this.data.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
const COMPANIES: ICompany[] = [
|
||||
{ Id: '9de064a4-44c8-88ef-11ec-8284930fad96', Name: 'ТОО TANSU Construction', Bin: '021040005165' },
|
||||
{ Id: '0a1b2c3d-0000-4000-8000-000000000002', Name: 'ТОО TANSU Строй' }
|
||||
];
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
|
||||
describe('кэш справочника компаний', () => {
|
||||
it('в пустом хранилище кэша нет', () => {
|
||||
expect(readCachedCompanies(new MemoryStorage(), NOW)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('записанное читается обратно, пока не прошло 4 часа', () => {
|
||||
const storage = new MemoryStorage();
|
||||
writeCachedCompanies(storage, COMPANIES, NOW);
|
||||
|
||||
expect(readCachedCompanies(storage, NOW + 1000)).toEqual(COMPANIES);
|
||||
expect(readCachedCompanies(storage, NOW + COMPANIES_CACHE_TTL_MS - 1)).toEqual(COMPANIES);
|
||||
});
|
||||
|
||||
it('через 4 часа кэш устаревает и удаляется', () => {
|
||||
const storage = new MemoryStorage();
|
||||
writeCachedCompanies(storage, COMPANIES, NOW);
|
||||
|
||||
expect(readCachedCompanies(storage, NOW + COMPANIES_CACHE_TTL_MS)).toBeUndefined();
|
||||
expect(storage.data.has(COMPANIES_CACHE_KEY)).toBe(false);
|
||||
});
|
||||
|
||||
it('срок — ровно 4 часа', () => {
|
||||
expect(COMPANIES_CACHE_TTL_MS).toBe(14_400_000);
|
||||
});
|
||||
|
||||
it('запись из будущего (часы переведены назад) не доверяется', () => {
|
||||
const storage = new MemoryStorage();
|
||||
writeCachedCompanies(storage, COMPANIES, NOW + 60_000);
|
||||
|
||||
expect(readCachedCompanies(storage, NOW)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('битый JSON и неожиданная форма — как отсутствующий кэш', () => {
|
||||
const broken = new MemoryStorage();
|
||||
broken.setItem(COMPANIES_CACHE_KEY, '{не json');
|
||||
expect(readCachedCompanies(broken, NOW)).toBeUndefined();
|
||||
expect(broken.data.has(COMPANIES_CACHE_KEY)).toBe(false);
|
||||
|
||||
const wrongShape = new MemoryStorage();
|
||||
wrongShape.setItem(COMPANIES_CACHE_KEY, JSON.stringify({ savedAt: NOW, items: [{ Id: 1 }] }));
|
||||
expect(readCachedCompanies(wrongShape, NOW)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('недоступное хранилище не ломает ни чтение, ни запись', () => {
|
||||
const throwing: IStorageLike = {
|
||||
getItem: () => {
|
||||
throw new Error('запрещено');
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('переполнено');
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error('запрещено');
|
||||
}
|
||||
};
|
||||
|
||||
expect(readCachedCompanies(throwing, NOW)).toBeUndefined();
|
||||
expect(() => writeCachedCompanies(throwing, COMPANIES, NOW)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ICompany } from './models/ICompany';
|
||||
|
||||
/**
|
||||
* Ключ в localStorage. Версия в имени: поменяется форма записи — старые кэши просто перестанут
|
||||
* читаться, а не сломают разбор.
|
||||
*/
|
||||
export const COMPANIES_CACHE_KEY = 'rp.dictionary.companies.v1';
|
||||
|
||||
/** Справочник компаний меняется редко, поэтому живёт в кэше 4 часа. */
|
||||
export const COMPANIES_CACHE_TTL_MS = 4 * 60 * 60 * 1000;
|
||||
|
||||
/** Тот минимум localStorage, который нужен кэшу — для подмены в тестах. */
|
||||
export interface IStorageLike {
|
||||
// Сигнатура повторяет DOM-интерфейс Storage, где «нет значения» — это null.
|
||||
// eslint-disable-next-line @rushstack/no-new-null
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
interface ICachedCompanies {
|
||||
savedAt: number;
|
||||
items: ICompany[];
|
||||
}
|
||||
|
||||
function isCompany(value: unknown): value is ICompany {
|
||||
const company = value as Partial<ICompany> | undefined;
|
||||
return !!company && typeof company.Id === 'string' && typeof company.Name === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Свежий кэш или undefined. Просроченный, битый или в неожиданной форме кэш считается отсутствующим
|
||||
* и удаляется: лучше лишний раз сходить в справочник, чем показать мусор.
|
||||
*/
|
||||
export function readCachedCompanies(storage: IStorageLike, now: number): ICompany[] | undefined {
|
||||
// eslint-disable-next-line @rushstack/no-new-null
|
||||
let raw: string | null;
|
||||
try {
|
||||
raw = storage.getItem(COMPANIES_CACHE_KEY);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!raw) return undefined;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<ICachedCompanies>;
|
||||
const fresh =
|
||||
typeof parsed.savedAt === 'number' &&
|
||||
now - parsed.savedAt >= 0 &&
|
||||
now - parsed.savedAt < COMPANIES_CACHE_TTL_MS &&
|
||||
Array.isArray(parsed.items) &&
|
||||
parsed.items.every(isCompany);
|
||||
if (fresh) return parsed.items as ICompany[];
|
||||
} catch {
|
||||
// битый JSON — как отсутствующий кэш
|
||||
}
|
||||
|
||||
try {
|
||||
storage.removeItem(COMPANIES_CACHE_KEY);
|
||||
} catch {
|
||||
// хранилище недоступно — удалять нечего
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Кладёт справочник в кэш. Переполнение или запрет хранилища (режим инкогнито) кэш не ломают — просто его не будет. */
|
||||
export function writeCachedCompanies(storage: IStorageLike, items: ICompany[], now: number): void {
|
||||
try {
|
||||
storage.setItem(COMPANIES_CACHE_KEY, JSON.stringify({ savedAt: now, items } as ICachedCompanies));
|
||||
} catch {
|
||||
// кэш — оптимизация, а не источник данных
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ async function fetchEmployeeOrganizations(context: WebPartContext, email: string
|
||||
id: (r.Id as string) || '',
|
||||
fio: (r.Fio as string) || '',
|
||||
email: (r.Email as string) || '',
|
||||
iin: (r.Iin as string) || '',
|
||||
company: (r.Company as string) || '',
|
||||
companyId: (r.CompanyId as string) || '',
|
||||
section: (r.Section as string) || '',
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { IEmployeeOrganization } from './models/IEmployeeOrganization';
|
||||
import { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines';
|
||||
|
||||
const rec = (p: Partial<IEmployeeOrganization>): IEmployeeOrganization => ({
|
||||
id: '', fio: '', email: '', iin: '', company: 'ТОО А', companyId: '', section: '', departmentId: '', position: '', mobile: '', ...p
|
||||
});
|
||||
|
||||
describe('организации с подразделением', () => {
|
||||
it('на каждую организацию своё подразделение', () => {
|
||||
const text = formatEmployeeOrganizationsWithDepartment([
|
||||
rec({ company: 'ТОО А', position: 'Директор', section: 'Дирекция' }),
|
||||
rec({ company: 'ТОО Б', position: 'Казначей', section: 'Финансы' })
|
||||
]);
|
||||
expect(text.split('\n').map(parseOrganizationLine)).toEqual([
|
||||
{ company: 'ТОО А', position: 'Директор', department: 'Дирекция' },
|
||||
{ company: 'ТОО Б', position: 'Казначей', department: 'Финансы' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('без подразделения формат прежний «Компания — должность»', () => {
|
||||
expect(formatEmployeeOrganizationsWithDepartment([rec({ position: 'Директор' })])).toBe('ТОО А — Директор');
|
||||
});
|
||||
|
||||
it('старые строки разбираются с пустым подразделением', () => {
|
||||
expect(parseOrganizationLine('ТОО А — Директор')).toEqual({ company: 'ТОО А', position: 'Директор', department: '' });
|
||||
expect(parseOrganizationLine('ТОО А')).toEqual({ company: 'ТОО А', position: '', department: '' });
|
||||
});
|
||||
});
|
||||
@@ -28,3 +28,5 @@ export {
|
||||
getCompanyDirectorEmail,
|
||||
getManagerEmail
|
||||
} from './employeeLookup.service';
|
||||
export { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines';
|
||||
export type { IOrganizationLine } from './organizationLines';
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// GET https://api.tnsu.kz/Dictionary/Companies → плоский массив { Id, Name }
|
||||
// GET https://api.tnsu.kz/Dictionary/Companies → плоский массив { Id, Name, Bin }
|
||||
// (PascalCase, без обёртки — как /Dictionary/Departments).
|
||||
export interface ICompany {
|
||||
/** GUID компании — стабильный идентификатор. */
|
||||
Id: string;
|
||||
/** Наименование (напр. «ТОО AFL Engineering») — то, что видит пользователь. */
|
||||
Name: string;
|
||||
/** БИН компании, 12 цифр. Нужен для ЭДО (от имени какой компании подписывается документ). */
|
||||
Bin?: string;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface IEmployeeOrganization {
|
||||
id: string;
|
||||
fio: string;
|
||||
email: string;
|
||||
/** ИИН сотрудника, 12 цифр. Нужен для ЭДО (по нему там заводится подписант). */
|
||||
iin: string;
|
||||
company: string;
|
||||
companyId: string;
|
||||
section: string;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IEmployeeOrganization } from './models/IEmployeeOrganization';
|
||||
|
||||
/**
|
||||
* Организации сотрудника с подразделением: «Компания — должность — подразделение»
|
||||
* на строку. Нужен формам, что показывают организации таблицей (см.
|
||||
* OrganizationTable). Первый сегмент по-прежнему компания — остальные разборщики
|
||||
* (OrgLetterhead и др.) берут только его и не ломаются. Нет подразделения —
|
||||
* формат прежний, как у formatEmployeeOrganizations.
|
||||
*/
|
||||
export function formatEmployeeOrganizationsWithDepartment(records: IEmployeeOrganization[]): string {
|
||||
return records
|
||||
.map((r) => {
|
||||
if (r.section) return [r.company, r.position, r.section].join(' — ');
|
||||
return r.position ? `${r.company} — ${r.position}` : r.company;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export interface IOrganizationLine {
|
||||
company: string;
|
||||
position: string;
|
||||
department: string;
|
||||
}
|
||||
|
||||
/** Разбор строки «Компания — должность[ — подразделение]»; старые строки без подразделения дают пустое. */
|
||||
export function parseOrganizationLine(line: string): IOrganizationLine {
|
||||
const [company = '', position = '', department = ''] = line.split(' — ').map((p) => p.trim());
|
||||
return { company, position, department };
|
||||
}
|
||||
Reference in New Issue
Block a user