feat: доработал заявку перемещение тмц
Tests / frontend-test (push) Successful in 4m45s

This commit is contained in:
Тимур Городецкий
2026-09-10 11:19:16 +05:00
parent c5958c3348
commit 11eb127764
17 changed files with 429 additions and 15 deletions
@@ -58,3 +58,31 @@ export const DICTIONARY_API_BASE_URL = 'https://api.tnsu.kz';
export const FLOW_RESOURCE = 'https://service.flow.microsoft.com/';
export const FLOW_NOTIFY_URL =
'https://eab3d2815f2beb42bf2e7ec1731708.92.environment.api.powerplatform.com:443/powerautomate/automations/direct/cu/07/workflows/b21516edf8a14287a65a39e4116dfe4f/triggers/manual/paths/invoke?api-version=1';
/**
* Microsoft Dataverse (Power Platform) — рабочая среда «По умолчанию»
* (org95ca17d6.crm4.dynamics.com). NB: таблица cree9_demandline существует и в
* другой среде (tansu.crm5, из проекта stroy-bot) — писать нужно ИМЕННО в эту.
* Авторизация через AadHttpClient (SSO текущего пользователя): ресурс = URL
* среды, разрешение объявлено в package-solution.json (webApiPermissionRequests,
* scope user_impersonation) и одобряется в Центре администрирования SharePoint
* (API access) при развёртывании — тот же механизм, что у approval-route API.
*
* Используется формой «СЗ на перемещение ТМЦ»: после отправки на согласование
* в строку-источник (таблица cree9_demandline) записывается ссылка на заявку
* портала — см. shared/dataverse/demandLine.service.ts.
*/
export const DATAVERSE_RESOURCE_URL = 'https://org95ca17d6.crm4.dynamics.com';
export const DATAVERSE_API_VERSION = 'v9.2';
/**
* EntitySetName таблицы «Строка заявки» (LogicalName cree9_demandline).
* NB: значение выведено по конвенции Dataverse (logical + 's') — проверить в
* проде через EntityDefinitions(LogicalName='cree9_demandline')?$select=EntitySetName.
*/
export const DATAVERSE_DEMANDLINE_ENTITY_SET = 'cree9_demandlines';
/**
* Колонка-URL, куда пишем ссылку на заявку портала (создана вручную в Dataverse).
* NB: префикс cr498_ (издатель среды org95ca17d6.crm4 по умолчанию), а НЕ cree9_
* как у остальных колонок таблицы — так сгенерировалось при создании колонки.
*/
export const DATAVERSE_DEMANDLINE_LINK_FIELD = 'cr498_portalrequesturl';
@@ -0,0 +1,58 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import {
DATAVERSE_API_VERSION,
DATAVERSE_DEMANDLINE_ENTITY_SET,
DATAVERSE_DEMANDLINE_LINK_FIELD,
DATAVERSE_RESOURCE_URL
} from '../config';
/**
* Обратная связь во внешнюю систему: записать ссылку на заявку портала в
* строку-источник таблицы Dataverse «Строка заявки» (cree9_demandline).
*
* positionId — GUID первичного ключа строки (приходит в URL как positionId, см.
* shared/routing). Адресуем строку напрямую: <entitySet>(<guid>). PATCH пишет
* URL в колонку cree9_portalrequesturl.
*
* Заголовок If-Match: * ОБЯЗАТЕЛЕН: без него PATCH в Dataverse — это upsert (нет
* строки с таким id → СОЗДАЁТ новую). Нам нужно строго обновление существующей
* строки-источника; If-Match: * это гарантирует — если строки нет, вернётся 404,
* а не появится «пустая» запись.
*
* Авторизация — AadHttpClient с ресурсом среды Dataverse (SSO текущего
* пользователя); заголовки OData — как в stroy-bot-report/DataverseService.
*/
export async function writeDemandLinePortalLink(
context: WebPartContext,
positionId: string,
portalRequestUrl: string
): Promise<void> {
const client = await context.aadHttpClientFactory.getClient(DATAVERSE_RESOURCE_URL);
const url =
`${DATAVERSE_RESOURCE_URL}/api/data/${DATAVERSE_API_VERSION}/` +
`${DATAVERSE_DEMANDLINE_ENTITY_SET}(${positionId})`;
const response: HttpClientResponse = await client.fetch(url, AadHttpClient.configurations.v1, {
method: 'PATCH',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json;odata.metadata=minimal',
'OData-MaxVersion': '4.0',
'OData-Version': '4.0',
// Только обновление существующей строки, без upsert-создания.
'If-Match': '*'
},
body: JSON.stringify({ [DATAVERSE_DEMANDLINE_LINK_FIELD]: portalRequestUrl })
});
if (!response.ok) {
let errBody = '';
try {
errBody = await response.text();
} catch {
// тело ошибки прочитать не удалось — оставляем пустым
}
throw new Error(`Dataverse PATCH ${DATAVERSE_DEMANDLINE_ENTITY_SET}: ${response.status} — ${errBody}`);
}
}
@@ -12,6 +12,8 @@ export { CompanyPickerMulti } from './ui/CompanyPickerMulti';
export type { ICompanyPickerMultiProps } from './ui/CompanyPickerMulti';
export { getEmployeeOrganizations, formatEmployeeOrganizations } from './employeeOrganizations.service';
export type { IEmployeeOrganization } from './models/IEmployeeOrganization';
export { getNomenclature } from './nomenclature.service';
export type { INomenclatureRow } from './models/INomenclatureRow';
export {
directorPositionFor,
getEmployeeEmailByPosition,
@@ -0,0 +1,32 @@
/**
* Строка ответа GET /Dictionary/Nomenclature/{id}. Эндпоинт возвращает МАССИВ:
* одну и ту же номенклатуру (общий id) в разбивке ПО СКЛАДАМ — у каждой строки
* свой storeName и свой остаток quantity. Заполняет табличную часть формы
* «СЗ на перемещение ТМЦ» (см. TmcTransferForm prefill).
*/
export interface INomenclatureRow {
/** Id номенклатуры (совпадает у всех строк ответа — это nomenclatureId из URL). */
id: string;
/** Наименование. */
name: string;
/** Полное наименование. */
fullName: string;
/** Группа номенклатуры. */
groupName: string;
/** Единица измерения (напр. «шт»). */
unit: string;
/** Остаток на складе (число). */
quantity: number;
/** Номенклатурный код (напр. «БК000003794»). */
code: string;
/** Код АГСК (напр. «247-201-1002»). */
agskCode: string;
/** Тип номенклатуры (напр. «Сырьё и материалы»). */
nomenclatureType: string;
/** Склад, на котором лежит остаток. */
storeName: string;
/** МОЛ (материально ответственное лицо). */
mol: string;
/** Комментарий. */
comment: string;
}
@@ -0,0 +1,32 @@
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 { INomenclatureRow } from './models/INomenclatureRow';
/**
* GET /Dictionary/Nomenclature/{id} — остатки номенклатуры по складам. Ответ —
* плоский массив (как /Dictionary/Departments): одна номенклатура в разбивке по
* складам. Авторизация — тот же AAD-app, что и у остальных справочников.
*
* storeName (необязательный) — фильтр по складу: API вернёт остатки только по
* нему. Приходит по внешней ссылке (см. shared/routing). Пусто — все склады.
*
* Кэш не нужен: вызывается один раз при открытии формы по внешней ссылке и
* зависит от конкретного id (не общий справочник на всю сессию).
*/
export async function getNomenclature(
context: WebPartContext,
id: string,
storeName?: string
): Promise<INomenclatureRow[]> {
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const query = storeName ? `?storeName=${encodeURIComponent(storeName)}` : '';
const response: HttpClientResponse = await client.get(
`${DICTIONARY_API_BASE_URL}/Dictionary/Nomenclature/${encodeURIComponent(id)}${query}`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`Dictionary/Nomenclature/${id}: ${response.status} ${response.statusText}`);
}
return response.json();
}
@@ -2,6 +2,7 @@ import {
IRoute,
navigate,
navigateToRequestLink,
newRequestLinkUrl,
parseRoute,
persistOwnStatus,
persistOwnTab,
@@ -96,6 +97,65 @@ describe('parseRoute', () => {
it('открытая заявка имеет приоритет над предзаполнением', () => {
expect(parseRoute('?rpmode=approve&rptype=hire&rpid=42&rpsource=15').mode).toBe('approve');
});
it('rpmode=create + rptype без rpsource — пустая новая форма без предзаполнения', () => {
expect(parseRoute('?rpmode=create&rptype=hire')).toEqual({
mode: 'create',
nav: 'new',
formKey: 'hire',
});
});
it('rptype БЕЗ rpmode — тоже пустая новая форма (buildQuery для create его не пишет)', () => {
expect(parseRoute('?rptype=hire')).toEqual({
mode: 'create',
nav: 'new',
formKey: 'hire',
});
});
it('rpmode=create без rptype не открывает пустую форму — остаётся обычная подача', () => {
expect(parseRoute('?rpmode=create').mode).toBe('submit');
});
it('внешняя ссылка ТМЦ: rptype + positionId + nomenclatureId + storeName прокидываются в create', () => {
expect(parseRoute('?rptype=wh-tmc-transfer&positionId=00d31059-1f52-11ec-a738-3cecef292546&nomenclatureId=abc-123&storeName=Склад%20№1')).toEqual({
mode: 'create',
nav: 'new',
formKey: 'wh-tmc-transfer',
positionId: '00d31059-1f52-11ec-a738-3cecef292546',
nomenclatureId: 'abc-123',
storeName: 'Склад №1',
});
});
it('positionId/nomenclatureId без rptype игнорируются — обычная подача', () => {
// Внешние параметры имеют смысл только при открытии формы (rptype); сами по
// себе не должны влиять на маршрут.
expect(parseRoute('?positionId=x&nomenclatureId=y').mode).toBe('submit');
});
it('rpmode=create + rptype + rpsource — предзаполнение важнее пустой формы', () => {
expect(parseRoute('?rpmode=create&rptype=hire&rpsource=15')).toEqual({
mode: 'create',
nav: 'new',
formKey: 'hire',
sourceId: 15,
});
});
});
describe('newRequestLinkUrl', () => {
it('строит абсолютную ссылку на пустую форму без rpsource', () => {
const url = newRequestLinkUrl('hire');
expect(url).toBe(`${window.location.origin}${window.location.pathname}?rpmode=create&rptype=hire`);
});
it('ссылка на пустую форму разбирается обратно в тот же маршрут', () => {
const url = newRequestLinkUrl('hire');
const route = parseRoute(new URL(url).search);
expect(route).toEqual({ mode: 'create', nav: 'new', formKey: 'hire' });
});
});
describe('подвкладки и фильтр «Моих заявок»', () => {
+54 -2
View File
@@ -12,6 +12,12 @@ import * as React from 'react';
// списке данных. Заявка показывается в выезжающей панели ПОВЕРХ текущего
// раздела, поэтому rpnav сохраняется вместе с rpmode.
//
// Ссылка на ПУСТУЮ новую форму конкретного типа (без предзаполнения, минуя
// плитки) — см. newRequestLinkUrl():
// …/СтраницаПортала.aspx?rpmode=create&rptype=<форма>
// Отличается от «создать на основе» (тот же rpmode=create, но с rpsource) —
// без rpsource форма просто открывается пустой.
//
// Префикс rp (request-portal): имена id/mode/type зарезервированы SharePoint
// (напр. ?id= он трактует как навигацию к элементу → «Элемент не существует»),
// поэтому используем собственные, неконфликтные имена параметров.
@@ -63,6 +69,23 @@ export interface IRoute {
listId?: string;
/** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */
sourceId?: number;
/**
* Id строки-источника во внешней системе (Dataverse «Строка заявки», GUID
* первичного ключа). Приходит по ссылке из внешней системы вместе с rptype —
* форма «СЗ на перемещение ТМЦ» по нему после отправки на согласование
* записывает обратно ссылку на заявку (см. shared/dataverse/demandLine.service).
*/
positionId?: string;
/**
* Id номенклатуры для GET /Dictionary/Nomenclature/{id} — форма предзаполняет
* табличную часть остатками по складам. Приходит по той же внешней ссылке.
*/
nomenclatureId?: string;
/**
* Склад-фильтр для GET /Dictionary/Nomenclature — вернуть остатки только по
* этому складу. Приходит по той же внешней ссылке; пусто — все склады.
*/
storeName?: string;
/** Подвкладка «Моих заявок»; задана только при nav='own'. */
ownTab?: OwnTab;
/** Фильтр по статусу заявки; задан только при nav='own' и ownTab='created'. */
@@ -310,6 +333,12 @@ export function parseRoute(search: string = window.location.search): IRoute {
const listId = params.get('rplist') ?? undefined;
const sourceRaw = params.get('rpsource');
const sourceId = sourceRaw ? Number(sourceRaw) : undefined;
// Параметры внешней ссылки (без rp-префикса — так их формирует внешняя
// система, менять её мы не можем). Актуальны только для сценария «создать» по
// rptype: заполнить форму ТМЦ из справочника и запомнить строку-источник.
const positionId = params.get('positionId') ?? undefined;
const nomenclatureId = params.get('nomenclatureId') ?? undefined;
const storeName = params.get('storeName') ?? undefined;
// Подвкладка и фильтр — состояние раздела «Мои заявки», поэтому вне его не
// читаются вовсе (иначе чужой rptab из ссылки «протёк» бы в другой раздел).
@@ -343,6 +372,17 @@ export function parseRoute(search: string = window.location.search): IRoute {
if (formKey && sourceId && !isNaN(sourceId)) {
return { mode: 'create', nav: 'new', formKey, sourceId };
}
// Ссылка на пустую новую форму конкретного типа — без предзаполнения
// (клик по плитке внутри портала, либо явная ссылка ?rptype=<форма> без
// rpsource — см. newRequestLinkUrl). Как и ветка с rpsource выше, НЕ требует
// rpmode=create: buildQuery() его для этого случая не пишет (значения по
// умолчанию в адрес не пишем), поэтому единственный сигнал — сам rptype.
// Но approve/edit явно исключаем: битая/неполная ссылка на них (напр.
// rpmode=approve без валидного rpid) должна откатываться на обычную подачу,
// а не тихо превращаться в открытие пустой формы другого режима.
if (formKey && mode !== 'approve' && mode !== 'edit') {
return { mode: 'create', nav: 'new', formKey, positionId, nomenclatureId, storeName };
}
return { mode: 'submit', nav, ownTab, ownStatus, page, query };
}
@@ -380,9 +420,9 @@ function buildQuery(route: IRoute): string {
// Партиция неизвестна (архивная ссылка старого формата, до этого поля) —
// ссылка всё равно валидна, форма/просмотр просто откатятся на текущую.
if (route.listId) params.set('rplist', route.listId);
} else if (route.mode === 'create' && route.formKey && route.sourceId) {
} else if (route.mode === 'create' && route.formKey) {
params.set('rptype', route.formKey);
params.set('rpsource', String(route.sourceId));
if (route.sourceId) params.set('rpsource', String(route.sourceId));
}
const query = params.toString();
@@ -438,6 +478,18 @@ export function requestLinkUrl(formKey: string, itemId: number, listId?: string)
return `${window.location.origin}${window.location.pathname}?${params.toString()}`;
}
/**
* АБСОЛЮТНАЯ ссылка на ПУСТУЮ новую форму конкретного типа — без предзаполнения,
* в отличие от requestLinkUrl (просмотр существующей). Для той же цели: письмо,
* сообщение в чат, кнопка «поделиться ссылкой на форму».
*/
export function newRequestLinkUrl(formKey: string): string {
const params = new URLSearchParams();
params.set('rpmode', 'create');
params.set('rptype', formKey);
return `${window.location.origin}${window.location.pathname}?${params.toString()}`;
}
/**
* Открыть заявку по её АБСОЛЮТНОЙ ссылке (см. requestLinkUrl) БЕЗ перезагрузки
* страницы — если ссылка ведёт на эту же страницу портала.
@@ -8,11 +8,21 @@ export interface ILineItem {
name: string;
/** Инвентарный номер. */
invNumber: string;
/** Номенклатурный код (из справочника, поле code). Необязательное — у строк,
* добавленных вручную/из Excel, может отсутствовать. */
code?: string;
/** Единица измерения (по умолчанию «шт»). */
unit: string;
/** Количество. */
qty: string;
/**
* Id номенклатуры-источника (для строк, предзаполненных из справочника). У
* строк, дописанных пользователем вручную, отсутствует — по наличию поля
* отличают «пришло из номенклатуры» от «добавлено руками». В UI не
* показывается, едет вместе с позицией в ItemsJson.
*/
nomenclatureId?: string;
}
/** Пустая строка позиции (для «Добавить строку»). Ед.изм по умолчанию «шт». */
export const EMPTY_LINE_ITEM: ILineItem = { name: '', invNumber: '', unit: 'шт', qty: '' };
export const EMPTY_LINE_ITEM: ILineItem = { name: '', invNumber: '', code: '', unit: 'шт', qty: '' };
@@ -51,6 +51,7 @@ export const ItemsTable: React.FC<IItemsTableProps> = ({ value, onChange, readOn
<th className={styles.num}>№</th>
<th>Наименование</th>
<th className={styles.inv}>Инв. номер</th>
<th className={styles.inv}>Код</th>
<th className={styles.unit}>Ед.изм</th>
<th className={styles.qty}>Кол-во</th>
</tr>
@@ -58,7 +59,7 @@ export const ItemsTable: React.FC<IItemsTableProps> = ({ value, onChange, readOn
<tbody>
{value.length === 0 ? (
<tr>
<td colSpan={5} className={styles.empty}>Позиции не добавлены</td>
<td colSpan={6} className={styles.empty}>Позиции не добавлены</td>
</tr>
) : (
value.map((row, i) => (
@@ -66,6 +67,7 @@ export const ItemsTable: React.FC<IItemsTableProps> = ({ value, onChange, readOn
<td className={styles.num}>{i + 1}</td>
<td>{row.name}</td>
<td>{row.invNumber}</td>
<td>{row.code}</td>
<td className={styles.unit}>{row.unit}</td>
<td className={styles.qty}>{row.qty}</td>
</tr>
@@ -86,6 +88,7 @@ export const ItemsTable: React.FC<IItemsTableProps> = ({ value, onChange, readOn
<th className={styles.num}>№</th>
<th>Наименование</th>
<th className={styles.inv}>Инв. номер</th>
<th className={styles.inv}>Код</th>
<th className={styles.unit}>Ед.изм</th>
<th className={styles.qty}>Кол-во</th>
<th className={styles.act} aria-label="Действия" />
@@ -94,7 +97,7 @@ export const ItemsTable: React.FC<IItemsTableProps> = ({ value, onChange, readOn
<tbody>
{value.length === 0 ? (
<tr>
<td colSpan={6} className={styles.empty}>
<td colSpan={7} className={styles.empty}>
Нет позиций. Добавьте строку или вставьте из Excel.
</td>
</tr>
@@ -116,6 +119,13 @@ export const ItemsTable: React.FC<IItemsTableProps> = ({ value, onChange, readOn
onChange={(e) => setCell(i, 'invNumber', e.target.value)}
/>
</td>
<td>
<input
className={styles.cell}
value={row.code ?? ''}
onChange={(e) => setCell(i, 'code', e.target.value)}
/>
</td>
<td>
<input
className={`${styles.cell} ${styles.cellCenter}`}