diff --git a/config/package-solution.json b/config/package-solution.json index 5c68af6..2307bd4 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -33,6 +33,10 @@ { "resource": "Microsoft Flow Service", "scope": "Flows.Read.All" + }, + { + "resource": "https://org95ca17d6.crm4.dynamics.com", + "scope": "user_impersonation" } ], "features": [ diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index a1bce34..784e954 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -367,17 +367,24 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context navigate({ mode: 'create', nav: 'new', formKey })} /> diff --git a/src/webparts/requestPortal/features/submit/SubmitFeature.tsx b/src/webparts/requestPortal/features/submit/SubmitFeature.tsx index a50b9af..525401f 100644 --- a/src/webparts/requestPortal/features/submit/SubmitFeature.tsx +++ b/src/webparts/requestPortal/features/submit/SubmitFeature.tsx @@ -15,10 +15,15 @@ export interface ISubmitFeatureProps { /** Активная категория. Вкладки теперь рендерятся на уровне портала (RequestPortal), * чтобы оставаться на экране и в режиме согласования — эта фича их только слушает. */ activeCategoryId: string; - /** Открыть сразу форму с предзаполнением (производная заявка), минуя плитки. */ - initialForm?: { formKey: string; sourceId: number }; + /** Открыть сразу форму, минуя плитки — с предзаполнением (производная заявка, + * sourceId задан) или без него (пустая новая форма конкретного типа). + * positionId/nomenclatureId — параметры внешней ссылки (только форма ТМЦ). */ + initialForm?: { formKey: string; sourceId?: number; positionId?: string; nomenclatureId?: string; storeName?: string }; /** Открыть сразу форму на РЕДАКТИРОВАНИЕ существующей заявки (черновик/возвращена). */ editForm?: { formKey: string; itemId: number }; + /** Пользователь выбрал плитку — сообщаем наверх, чтобы адрес страницы стал + * ссылкой на эту пустую форму (см. newRequestLinkUrl в shared/routing). */ + onFormSelected?: (formKey: string) => void; } // Фича «Подать заявку»: плитки → беспоук-форма (категории/вкладки — в RequestPortal). @@ -28,6 +33,7 @@ const SubmitFeature: React.FC = ({ activeCategoryId, initialForm, editForm, + onFormSelected, }) => { // «Создать на основе» и «редактировать» оба сразу открывают нужную форму, минуя плитки. const preOpen = initialForm ?? editForm; @@ -84,6 +90,9 @@ const SubmitFeature: React.FC = ({ const sourceId = initialForm && request.id === initialForm.formKey ? initialForm.sourceId : undefined; const editId = editForm && request.id === editForm.formKey ? editForm.itemId : undefined; + // Параметры внешней ссылки — только когда открыта именно та форма, для + // которой они пришли (formKey совпал). + const external = initialForm && request.id === initialForm.formKey ? initialForm : undefined; // Ленивый рендер формы: её код подтягивается только сейчас, при открытии. return ( @@ -95,17 +104,30 @@ const SubmitFeature: React.FC = ({ onSaved={emitRequestsChanged} sourceId={sourceId} editId={editId} + positionId={external?.positionId} + nomenclatureId={external?.nomenclatureId} + storeName={external?.storeName} /> ); }; + // Выбор плитки — переключаем и локальный стейт (форма рисуется сразу же), и + // сообщаем наверх formKey, чтобы адрес страницы стал ссылкой на эту форму + // (см. onFormSelected). RequestPortal в ответ вызовет navigate(), отчего + // SubmitFeature пересоздастся (другой key) — initialForm подхватит тот же + // formKey, так что видимый результат не изменится, просто через новый инстанс. + const handleSelect = (request: IRequestType): void => { + setSelectedRequest(request); + onFormSelected?.(request.id); + }; + return (
{selectedRequest ? ( renderSelected(selectedRequest) ) : ( - + )}
); diff --git a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx index 462a9fd..9333134 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx +++ b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx @@ -36,7 +36,15 @@ import { IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { effectiveUserEmail } from '../../shared/impersonation'; -import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; +import { + getEmployeeOrganizations, + formatEmployeeOrganizations, + getNomenclature, + INomenclatureRow +} from '../../shared/dictionary'; +import { ILineItem } from '../../shared/ui'; +import { requestLinkUrl } from '../../shared/routing'; +import { writeDemandLinePortalLink } from '../../shared/dataverse/demandLine.service'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { IRequestFormProps } from '../../models/types'; import { @@ -52,6 +60,23 @@ import { HeaderSection } from './sections/HeaderSection'; import { ItemsSection } from './sections/ItemsSection'; import { useStableCallback } from '../../shared/useStableCallback'; +/** + * Строка ответа номенклатуры → строка табличной части (name/код/ед.изм/кол-во). + * nomenclatureId (row.id) сохраняем в позиции: по нему потом отличают строки из + * справочника от дописанных пользователем вручную. + * NB: «Инв. номер» в ответе номенклатуры отсутствует — оставляем пустым. + */ +function nomenclatureRowToLineItem(row: INomenclatureRow): ILineItem { + return { + name: row.name || row.fullName || '', + invNumber: '', + code: row.code || '', + unit: row.unit || '', + qty: row.quantity !== undefined && row.quantity !== null ? String(row.quantity) : '', + nomenclatureId: row.id + }; +} + const TmcTransferForm: React.FC = ({ requestType, context, @@ -59,7 +84,10 @@ const TmcTransferForm: React.FC = ({ onSaved, editId, editListId, - onCodeLoaded + onCodeLoaded, + positionId, + nomenclatureId, + storeName }) => { const [data, setData] = useState(EMPTY_DATA); const [files, setFiles] = useState([]); @@ -112,6 +140,38 @@ const TmcTransferForm: React.FC = ({ }; }, [editId, context]); + // Открытие по внешней ссылке (positionId + nomenclatureId в URL): запоминаем + // строку-источник и предзаполняем таблицу остатками номенклатуры по складам. + // Только для новой заявки — при редактировании позиции уже сохранены. + useEffect(() => { + if (editId || !nomenclatureId) { + return; + } + // Строку-источник фиксируем сразу, независимо от ответа справочника: она + // нужна при отправке для обратной записи ссылки в Dataverse. nomenclatureId + // отдельно в шапке не храним — он едет в каждой позиции ItemsJson. + setData((prev) => ({ ...prev, SourcePositionId: positionId || '' })); + let alive = true; + getNomenclature(context, nomenclatureId, storeName) + .then((rows) => { + if (!alive) { + return; + } + const items = rows.map(nomenclatureRowToLineItem); + if (items.length > 0) { + setData((prev) => ({ ...prev, Items: items })); + } + }) + .catch((e) => { + if (alive) { + setSubmitError(`Не удалось загрузить номенклатуру: ${friendlyErrorMessage(e)}`); + } + }); + return () => { + alive = false; + }; + }, [editId, nomenclatureId, positionId, storeName, context, setSubmitError]); + // Выбор другого заявителя подтягивает его ФИО (для печати) и организации (как // в Vacation.handleEmployeeChange, но источник тот же Employee/ByEmail — здесь // дополнительно берём ФИО, т.к. TMC хранит его отдельным полем AuthorName). @@ -290,6 +350,19 @@ const TmcTransferForm: React.FC = ({ dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID) }); + // Обратная связь во внешнюю систему: пишем ссылку на эту заявку в + // строку-источник Dataverse (только если заявка открыта по внешней ссылке). + // Не критично для самой заявки — она уже сохранена и отправлена, поэтому + // сбой записи не роняем, а только логируем (см. флаг ниже для UX-решения). + if (data.SourcePositionId) { + const link = requestLinkUrl(FORM_ID, id, writeListId); + try { + await writeDemandLinePortalLink(context, data.SourcePositionId, link); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Не удалось записать ссылку в строку-источник (Dataverse):', e); + } + } } }; diff --git a/src/webparts/requestPortal/forms/TmcTransfer/load.ts b/src/webparts/requestPortal/forms/TmcTransfer/load.ts index 677bf33..dddfacf 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/load.ts +++ b/src/webparts/requestPortal/forms/TmcTransfer/load.ts @@ -51,6 +51,7 @@ export async function loadTmcTransferRequest(itemId: number, listId?: string): P 'Reason', 'ContactPerson', 'ContactPhone', + 'SourcePositionId', 'ItemsJson', 'RequestStatus', 'RequestUniqueCode' @@ -76,6 +77,7 @@ export async function loadTmcTransferRequest(itemId: number, listId?: string): P Reason: (item.Reason as string) || '', ContactPerson: (item.ContactPerson as string) || '', ContactPhone: (item.ContactPhone as string) || '', + SourcePositionId: (item.SourcePositionId as string) || '', Items: parseItems(item.ItemsJson) }; diff --git a/src/webparts/requestPortal/forms/TmcTransfer/model.ts b/src/webparts/requestPortal/forms/TmcTransfer/model.ts index 9461feb..c0478e9 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/model.ts +++ b/src/webparts/requestPortal/forms/TmcTransfer/model.ts @@ -54,7 +54,16 @@ export interface IFormData { ContactPerson: string; // Исполнитель («Исп. …»), Ф.И.О. ContactPhone: string; // Телефон исполнителя - // Табличная часть — позиции ТМЦ (сериализуется в ItemsJson) + /** + * Строка-источник во внешней системе (Dataverse «Строка заявки», GUID). Одна + * на всю заявку (не на позицию) — приходит в URL как positionId. После отправки + * на согласование по нему пишем обратно ссылку на заявку. '' — заявка заведена + * вручную в портале, без внешней ссылки. + */ + SourcePositionId: string; + + // Табличная часть — позиции ТМЦ (сериализуется в ItemsJson). У строк из + // справочника есть nomenclatureId (см. ILineItem), у дописанных вручную — нет. Items: ILineItem[]; } @@ -97,5 +106,6 @@ export const EMPTY_DATA: IFormData = { Reason: '', ContactPerson: '', ContactPhone: '', + SourcePositionId: '', Items: [] }; diff --git a/src/webparts/requestPortal/forms/TmcTransfer/provisioning.json b/src/webparts/requestPortal/forms/TmcTransfer/provisioning.json index 7c11703..7aab9cc 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/provisioning.json +++ b/src/webparts/requestPortal/forms/TmcTransfer/provisioning.json @@ -23,6 +23,8 @@ { "internalName": "ContactPerson", "title": "Исполнитель (Ф.И.О.)", "type": "Text" }, { "internalName": "ContactPhone", "title": "Телефон исполнителя", "type": "Text" }, + { "internalName": "SourcePositionId", "title": "Id строки-источника (Dataverse)", "type": "Text" }, + { "internalName": "ItemsJson", "title": "Позиции ТМЦ (JSON)", "type": "Note" } ] } diff --git a/src/webparts/requestPortal/models/types.ts b/src/webparts/requestPortal/models/types.ts index 6fbc22b..1744908 100644 --- a/src/webparts/requestPortal/models/types.ts +++ b/src/webparts/requestPortal/models/types.ts @@ -37,6 +37,16 @@ export interface IRequestFormProps { * Формы, не поддерживающие производные заявки, игнорируют. */ sourceId?: number; + /** + * Параметры внешней ссылки (Dataverse «Строка заявки» + справочник + * номенклатуры). Использует только форма «СЗ на перемещение ТМЦ»: по + * nomenclatureId предзаполняет таблицу остатками, positionId запоминает как + * строку-источник для обратной записи ссылки. Остальные формы игнорируют. + */ + positionId?: string; + nomenclatureId?: string; + /** Склад-фильтр для справочника номенклатуры (см. positionId). */ + storeName?: string; /** * Id СВОЕЙ заявки, открытой на редактирование (черновик / возвращённая / * отклонённая). В отличие от sourceId это не предзаполнение новой заявки, а diff --git a/src/webparts/requestPortal/shared/config.ts b/src/webparts/requestPortal/shared/config.ts index e3dc1bd..55c093f 100644 --- a/src/webparts/requestPortal/shared/config.ts +++ b/src/webparts/requestPortal/shared/config.ts @@ -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'; diff --git a/src/webparts/requestPortal/shared/dataverse/demandLine.service.ts b/src/webparts/requestPortal/shared/dataverse/demandLine.service.ts new file mode 100644 index 0000000..6c0718a --- /dev/null +++ b/src/webparts/requestPortal/shared/dataverse/demandLine.service.ts @@ -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). Адресуем строку напрямую: (). 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 { + 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}`); + } +} diff --git a/src/webparts/requestPortal/shared/dictionary/index.ts b/src/webparts/requestPortal/shared/dictionary/index.ts index e92ec68..a7a16e4 100644 --- a/src/webparts/requestPortal/shared/dictionary/index.ts +++ b/src/webparts/requestPortal/shared/dictionary/index.ts @@ -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, diff --git a/src/webparts/requestPortal/shared/dictionary/models/INomenclatureRow.ts b/src/webparts/requestPortal/shared/dictionary/models/INomenclatureRow.ts new file mode 100644 index 0000000..e13658c --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/models/INomenclatureRow.ts @@ -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; +} diff --git a/src/webparts/requestPortal/shared/dictionary/nomenclature.service.ts b/src/webparts/requestPortal/shared/dictionary/nomenclature.service.ts new file mode 100644 index 0000000..2d0d5a2 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/nomenclature.service.ts @@ -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 { + 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(); +} diff --git a/src/webparts/requestPortal/shared/routing.test.ts b/src/webparts/requestPortal/shared/routing.test.ts index ad5fef8..5f0ec8a 100644 --- a/src/webparts/requestPortal/shared/routing.test.ts +++ b/src/webparts/requestPortal/shared/routing.test.ts @@ -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('подвкладки и фильтр «Моих заявок»', () => { diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index 9637af7..39a8107 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -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) БЕЗ перезагрузки * страницы — если ссылка ведёт на эту же страницу портала. diff --git a/src/webparts/requestPortal/shared/ui/ItemsTable/ILineItem.ts b/src/webparts/requestPortal/shared/ui/ItemsTable/ILineItem.ts index 6542bcb..f8b187e 100644 --- a/src/webparts/requestPortal/shared/ui/ItemsTable/ILineItem.ts +++ b/src/webparts/requestPortal/shared/ui/ItemsTable/ILineItem.ts @@ -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: '' }; diff --git a/src/webparts/requestPortal/shared/ui/ItemsTable/ItemsTable.tsx b/src/webparts/requestPortal/shared/ui/ItemsTable/ItemsTable.tsx index 105b859..6a12670 100644 --- a/src/webparts/requestPortal/shared/ui/ItemsTable/ItemsTable.tsx +++ b/src/webparts/requestPortal/shared/ui/ItemsTable/ItemsTable.tsx @@ -51,6 +51,7 @@ export const ItemsTable: React.FC = ({ value, onChange, readOn № Наименование Инв. номер + Код Ед.изм Кол-во @@ -58,7 +59,7 @@ export const ItemsTable: React.FC = ({ value, onChange, readOn {value.length === 0 ? ( - Позиции не добавлены + Позиции не добавлены ) : ( value.map((row, i) => ( @@ -66,6 +67,7 @@ export const ItemsTable: React.FC = ({ value, onChange, readOn {i + 1} {row.name} {row.invNumber} + {row.code} {row.unit} {row.qty} @@ -86,6 +88,7 @@ export const ItemsTable: React.FC = ({ value, onChange, readOn № Наименование Инв. номер + Код Ед.изм Кол-во @@ -94,7 +97,7 @@ export const ItemsTable: React.FC = ({ value, onChange, readOn {value.length === 0 ? ( - + Нет позиций. Добавьте строку или вставьте из Excel. @@ -116,6 +119,13 @@ export const ItemsTable: React.FC = ({ value, onChange, readOn onChange={(e) => setCell(i, 'invNumber', e.target.value)} /> + + setCell(i, 'code', e.target.value)} + /> +