import fetchHelper from './fetchHelper' /** * Creates a tracking ticket (r_request) for a production (m_production). * * The ticket links the production (M_Production_ID), the sales order when one * exists (C_Order_ID), the produced product (M_Product_ID) and always the * merchant org (AD_Org_ID). C_BPartner_ID is set to the business partner * linked to that organization (ad_org.C_BPartner_ID) — NOT the order's * customer — so time booked on the ticket is billed to the merchant. * * Callers must treat a throw as non-fatal: the production itself is already * created and must never be rolled back because the ticket failed. * * `cache` (optional) avoids refetching the org bpartner / request type when * creating many tickets in one call (BOM production batch). */ export interface ProductionTicketOpts { organizationId: number | string productionId: number | string productionDocumentNo?: string productId?: number | string productName?: string qty?: number | string orderId?: number | string orderDocumentNo?: string } /** * Resolves the sales order a production was created for, via * m_production.C_OrderLine_ID → c_orderline.C_Order_ID. Returns nulls when the * production has no order line (manual productions). Two bounded $select GETs. */ export const resolveProductionOrder = async (event: any, token: string | null, productionId: number | string) => { const empty = { orderId: null as number | null, orderLineId: null as number | null, orderDocumentNo: '' } if (!productionId) return empty const prod: any = await fetchHelper(event, `models/m_production/${productionId}?$select=C_OrderLine_ID,DocumentNo`, 'GET', token, null) const orderLineId = prod?.C_OrderLine_ID?.id ?? null if (!orderLineId) return empty const line: any = await fetchHelper(event, `models/c_orderline/${orderLineId}?$select=C_Order_ID`, 'GET', token, null) const orderId = line?.C_Order_ID?.id ?? null return { orderId, orderLineId, orderDocumentNo: String(line?.C_Order_ID?.identifier || '').split('_')[0] } } /** * Ticket write rule: relating a production to a ticket also relates the order * that production was made for — unless the caller already sends an order. * Mutates `newObjValue` (C_Order_ID) and returns the resolved order id, or null * when nothing was changed. `currentOrderId` / `currentProductionId` are the * ticket's persisted values (update path) so an unchanged production link on a * ticket that already has an order is left alone. */ export const linkOrderFromProduction = async ( event: any, token: string | null, body: any, newObjValue: any, current: { orderId?: number | null, productionId?: number | null } = {} ): Promise => { const productionId = body?.productionId if (!productionId || body?.orderId) return null const linkIsNew = current.productionId == null || Number(current.productionId) !== Number(productionId) const hasOrder = !!current.orderId if (!linkIsNew && hasOrder) return null try { const { orderId } = await resolveProductionOrder(event, token, productionId) if (!orderId) return null newObjValue.C_Order_ID = { id: orderId, tableName: 'C_Order' } return orderId } catch (err: any) { console.warn('[productionTicket] could not derive order from production', productionId, err?.message || err) return null } } export const createProductionTicket = async (event: any, token: string, opts: ProductionTicketOpts, cache: any = {}) => { // Business partner linked to the merchant org (same resolution as // /api/admin/organizations/[id]/bpartner — one bounded $select fetch). if (!cache.orgBPartner) cache.orgBPartner = {} let orgBPartnerId = cache.orgBPartner[opts.organizationId] if (orgBPartnerId === undefined) { const orgRes: any = await fetchHelper(event, `models/ad_org/${opts.organizationId}?$select=C_BPartner_ID`, 'GET', token, null) orgBPartnerId = orgRes?.C_BPartner_ID?.id ?? null cache.orgBPartner[opts.organizationId] = orgBPartnerId } // First active request type — same fallback the request create page uses. if (cache.requestTypeId === undefined) { const rtRes: any = await fetchHelper(event, `models/r_requesttype?$filter=${encodeURIComponent('IsActive eq true')}&$top=1`, 'GET', token, null) cache.requestTypeId = rtRes?.records?.[0]?.id ?? null } if (!cache.requestTypeId) throw new Error('No active request type (r_requesttype) found') const productName = String(opts.productName || '').replace(/^-1_/, '') const qty = Number(opts.qty || 0) const summaryParts = [ `Produktion ${opts.productionDocumentNo || opts.productionId}` + (productName ? `: ${productName}` : '') + (qty > 0 ? ` (${qty} Stk)` : '') + (opts.orderDocumentNo ? ` — Auftrag ${opts.orderDocumentNo}` : ''), 'Ticket zur Erfassung von Zeiten und Arbeitsschritten dieser Produktion.' ] let refValues: any = {} if (orgBPartnerId) { refValues = {...refValues, C_BPartner_ID: { id: orgBPartnerId, tableName: 'C_BPartner' } } } if (opts.orderId) { refValues = {...refValues, C_Order_ID: { id: opts.orderId, tableName: 'C_Order' } } } if (opts.productId) { refValues = {...refValues, M_Product_ID: { id: opts.productId, tableName: 'M_Product' } } } const salesRepId = getCookie(event, 'logship_user_id') if (salesRepId) { refValues = {...refValues, SalesRep_ID: { id: Number(salesRepId), tableName: 'AD_User' } } } const res: any = await fetchHelper(event, 'models/r_request', 'POST', token, { AD_Org_ID: { id: opts.organizationId, tableName: 'AD_Org' }, R_RequestType_ID: { id: cache.requestTypeId, tableName: 'R_RequestType' }, M_Production_ID: { id: opts.productionId, tableName: 'M_Production' }, Priority: { id: '5' }, DueType: { id: '5' }, ConfidentialType: { id: 'I' }, ConfidentialTypeEntry: { id: 'I' }, summary: summaryParts.join('\n'), dateLastAction: new Date().toJSON().replace(/\.\d{3}Z$/, 'Z'), isActive: true, isEscalated: false, processed: false, isSelfService: false, isInvoiced: false, requestAmt: 0, qtySpent: 0, qtyInvoiced: 0, ...refValues, tableName: 'R_Request' }) if (!res?.id) throw new Error('Ticket (r_request) could not be created') return res }