import { graphql } from "../../src/graphql"; import { resolveTailorAuth } from "../utils/auth-machine-user"; import { test as base, expect } from "./user"; // --------------------------------------------------------------------------- // This flow (inventory-cost-tracking) is API-driven and read-only for the // signed-in user: valuation policy assignments and standard cost generations // are created through backend mutations (setItemValuationPolicy / // publishStandardCost), receipts are costed at the effective standard when an // inbound shipment settles, and the user only views the results. Auth comes // from the shared ./user fixture (a real OAuth login as a broadly-permissioned // admin); this file only adds the backend orchestration that produces an // assigned, costed, and received item so the screens have data to show. // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // GraphQL documents // --------------------------------------------------------------------------- const MasterDataQuery = graphql(` query IctMasterData { companies(first: 1) { edges { node { id baseCurrencyId } } } supplierAccounts(first: 1) { edges { node { id } } } sites(first: 1, query: { type: { eq: "WAREHOUSE" } }) { edges { node { id } } } items(first: 1, query: { status: { eq: ACTIVE } }) { edges { node { id name sku unitId } } } valuationPolicies(first: 1) { edges { node { id name companyId } } } } `); const CreatePurchaseOrderMutation = graphql(` mutation IctCreatePurchaseOrder( $companyId: String! $currencyId: String! $supplierAccountId: String! $orderDate: String! $receivingSiteId: String $lines: [CreatePurchaseOrderInputLines!]! ) { createPurchaseOrder( companyId: $companyId currencyId: $currencyId supplierAccountId: $supplierAccountId orderDate: $orderDate receivingSiteId: $receivingSiteId lines: $lines ) { id } } `); const SubmitPurchaseOrderMutation = graphql(` mutation IctSubmitPurchaseOrder($id: String!) { submitPurchaseOrder(id: $id) { id status } } `); const ApprovePurchaseOrderMutation = graphql(` mutation IctApprovePurchaseOrder($id: String!) { approvePurchaseOrder(id: $id) { id status } } `); const CreateItemMutation = graphql(` mutation IctCreateItem($sku: String!, $name: String!, $unitId: String!) { createItem(sku: $sku, name: $name, unitId: $unitId) { id } } `); const CreatePurchaseItemMutation = graphql(` mutation IctCreatePurchaseItem($itemId: String!, $requiresPhysicalReceipt: Boolean!) { createPurchaseItem(itemId: $itemId, requiresPhysicalReceipt: $requiresPhysicalReceipt) { id } } `); const SetItemValuationPolicyMutation = graphql(` mutation IctSetItemValuationPolicy( $itemId: String! $companyId: String! $valuationPolicyId: String! ) { setItemValuationPolicy( itemId: $itemId companyId: $companyId valuationPolicyId: $valuationPolicyId ) { id } } `); const PublishStandardCostMutation = graphql(` mutation IctPublishStandardCost($itemId: String!, $unitCost: String!) { publishStandardCost(itemId: $itemId, unitCost: $unitCost) { id } } `); const PurchaseOrderLinesQuery = graphql(` query IctPurchaseOrderLines($id: ID!) { purchaseOrder(id: $id) { id companyId supplierAccountId lines(first: 100) { edges { node { id itemId unitId quantity } } } } } `); const CreateInboundShipmentMutation = graphql(` mutation IctCreateInboundShipment( $companyId: String! $supplierAccountId: String! $receiptDate: String $lines: [CreateInboundShipmentInputLines!]! ) { createInboundShipment( companyId: $companyId supplierAccountId: $supplierAccountId receiptDate: $receiptDate lines: $lines ) { id } } `); const InboundShipmentDocQuery = graphql(` query IctInboundShipmentDoc($id: ID!) { inboundShipment(id: $id) { id docNumber } } `); const SettleInboundShipmentMutation = graphql(` mutation IctSettleInboundShipment($id: String!) { settleInboundShipment(id: $id) { id status } } `); const StandardCostsByItemQuery = graphql(` query IctStandardCostsByItem($itemId: ID!) { standardCosts( query: { itemId: { eq: $itemId } } order: [{ field: version, direction: Desc }] ) { total edges { node { id unitCost version createdAt } } } } `); const ItemValuationsByItemQuery = graphql(` query IctItemValuationsByItem($itemId: ID!) { itemValuations(query: { itemId: { eq: $itemId } }) { total edges { node { id companyId valuationPolicyId } } } } `); // --------------------------------------------------------------------------- // Fixture types // --------------------------------------------------------------------------- /** An assigned, costed, and received item and the valuation data it produced. */ export interface CostTrackingData { itemId: string; itemName: string; itemSku: string; /** PO line unit price the receipt settled at. */ unitCost: number; receivedQuantity: number; purchaseOrderId: string; inboundShipmentId: string; inboundShipmentDocNumber: string; /** Name of the ValuationPolicy assigned to the item. */ valuationPolicyName: string; itemValuationId: string; /** First published standard (superseded by the republication). */ firstStandardCost: number; /** Second published standard — the effective one. */ secondStandardCost: number; standardCostGenerationCount: number; } interface CostTrackingFixtures { costData: CostTrackingData; } // --------------------------------------------------------------------------- // Backend helpers (machine-user GraphQL client) // --------------------------------------------------------------------------- /** * Drive the backend operations for this flow: create a fresh item, assign the * seeded valuation policy to it (setItemValuationPolicy), publish a first * standard cost generation, receive it through a settled PO-backed inbound * shipment (costed at that standard), then publish a second generation so the * screens can show a real standard cost history with an unambiguous effective * standard. */ async function seedCostedReceivedItem(): Promise { const { graphqlClient } = resolveTailorAuth(); const master = await graphqlClient.query(MasterDataQuery, {}).toPromise(); if (master.error || !master.data) { throw new Error(`Failed to load master data: ${master.error?.message ?? "no data"}`); } const companyId = master.data.companies?.edges[0]?.node.id; const currencyId = master.data.companies?.edges[0]?.node.baseCurrencyId; const supplierAccountId = master.data.supplierAccounts?.edges[0]?.node.id; const siteId = master.data.sites?.edges[0]?.node.id; const seedItem = master.data.items?.edges[0]?.node; const valuationPolicy = master.data.valuationPolicies?.edges[0]?.node; if (!companyId || !currencyId || !supplierAccountId || !siteId || !seedItem || !valuationPolicy) { throw new Error( "Seed master data (company/base currency/supplier/site/item/valuationPolicy) is missing", ); } if (!seedItem.unitId) { throw new Error("Seed item has no unitId"); } // Create a unique item so its cost layers are unambiguously produced by this // fixture run and are not polluted by other tests. const unique = `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; const itemSku = `ICT-${unique}`; const itemName = `ICT Item ${unique}`; const createItemResult = await graphqlClient .mutation(CreateItemMutation, { sku: itemSku, name: itemName, unitId: seedItem.unitId, }) .toPromise(); if (createItemResult.error || !createItemResult.data) { throw new Error(`Failed to create item: ${createItemResult.error?.message ?? "no data"}`); } const item = createItemResult.data.createItem; // Ordering the item requires its purchasing record. const adopted = await graphqlClient .mutation(CreatePurchaseItemMutation, { itemId: item.id, requiresPhysicalReceipt: true }) .toPromise(); if (adopted.error) { throw new Error(`Failed to create purchasing record: ${adopted.error.message}`); } // Assign the company's valuation policy to the item explicitly (the story's // assignment step; a first movement would otherwise auto-assign the default). const assignResult = await graphqlClient .mutation(SetItemValuationPolicyMutation, { itemId: item.id, companyId, valuationPolicyId: valuationPolicy.id, }) .toPromise(); if (assignResult.error) { throw new Error(`Failed to assign valuation policy: ${assignResult.error.message}`); } // First standard cost generation: the receipt below is costed at this value. const firstStandardCost = 40; const secondStandardCost = 45; const firstPublish = await graphqlClient .mutation(PublishStandardCostMutation, { itemId: item.id, unitCost: String(firstStandardCost), }) .toPromise(); if (firstPublish.error) { throw new Error(`Failed to publish standard cost: ${firstPublish.error.message}`); } const unitCost = 42; const receivedQuantity = 5; const orderDate = new Date().toISOString().slice(0, 10); const poResult = await graphqlClient .mutation(CreatePurchaseOrderMutation, { companyId, currencyId, supplierAccountId, orderDate, receivingSiteId: siteId, lines: [ { itemId: item.id, quantity: String(receivedQuantity), unitPrice: String(unitCost), unitId: seedItem.unitId, receivingSiteId: siteId, }, ], }) .toPromise(); if (poResult.error || !poResult.data) { throw new Error(`Failed to create purchase order: ${poResult.error?.message ?? "no data"}`); } const purchaseOrderId = poResult.data.createPurchaseOrder.id; const submitResult = await graphqlClient .mutation(SubmitPurchaseOrderMutation, { id: purchaseOrderId }) .toPromise(); if (submitResult.error) { throw new Error(`Failed to submit purchase order: ${submitResult.error.message}`); } const approveResult = await graphqlClient .mutation(ApprovePurchaseOrderMutation, { id: purchaseOrderId }) .toPromise(); if (approveResult.error) { throw new Error(`Failed to approve purchase order: ${approveResult.error.message}`); } const poLines = await graphqlClient .query(PurchaseOrderLinesQuery, { id: purchaseOrderId }) .toPromise(); if (poLines.error || !poLines.data?.purchaseOrder) { throw new Error(`Failed to read PO lines: ${poLines.error?.message ?? "no data"}`); } const poLine = poLines.data.purchaseOrder.lines.edges[0]?.node; if (!poLine) { throw new Error("Approved purchase order has no lines"); } const inboundResult = await graphqlClient .mutation(CreateInboundShipmentMutation, { companyId, supplierAccountId, receiptDate: orderDate, lines: [ { purchaseOrderLineId: poLine.id, itemId: poLine.itemId, unitId: poLine.unitId ?? seedItem.unitId, quantity: String(receivedQuantity), destinationSite: siteId, }, ], }) .toPromise(); if (inboundResult.error || !inboundResult.data) { throw new Error( `Failed to create inbound shipment: ${inboundResult.error?.message ?? "no data"}`, ); } const inboundShipmentId = inboundResult.data.createInboundShipment.id; const settleResult = await graphqlClient .mutation(SettleInboundShipmentMutation, { id: inboundShipmentId }) .toPromise(); if (settleResult.error) { throw new Error(`Failed to settle inbound shipment: ${settleResult.error.message}`); } const docResult = await graphqlClient .query(InboundShipmentDocQuery, { id: inboundShipmentId }) .toPromise(); if (docResult.error || !docResult.data?.inboundShipment) { throw new Error( `Failed to read inbound shipment doc: ${docResult.error?.message ?? "no data"}`, ); } // Republish so the screens can show a real generation history: the second // generation becomes the effective standard and revalues the 5 units on hand. const secondPublish = await graphqlClient .mutation(PublishStandardCostMutation, { itemId: item.id, unitCost: String(secondStandardCost), }) .toPromise(); if (secondPublish.error) { throw new Error(`Failed to republish standard cost: ${secondPublish.error.message}`); } // Read back the rows the screens render; poll to absorb read-after-write lag. const generations = await pollFor(async () => { const res = await graphqlClient .query(StandardCostsByItemQuery, { itemId: item.id }, { requestPolicy: "network-only" }) .toPromise(); const edges = res.data?.standardCosts?.edges ?? []; return edges.length >= 2 ? edges : null; }, "standard cost generations"); const valuationNode = await pollFor(async () => { const res = await graphqlClient .query(ItemValuationsByItemQuery, { itemId: item.id }, { requestPolicy: "network-only" }) .toPromise(); return res.data?.itemValuations?.edges[0]?.node ?? null; }, "item valuation"); return { itemId: item.id, itemName, itemSku, unitCost, receivedQuantity, purchaseOrderId, inboundShipmentId, inboundShipmentDocNumber: docResult.data.inboundShipment.docNumber, valuationPolicyName: valuationPolicy.name, itemValuationId: valuationNode.id, firstStandardCost, secondStandardCost, standardCostGenerationCount: generations.length, }; } async function pollFor(read: () => Promise, what: string): Promise { const deadline = Date.now() + 30_000; let last: T | null = null; while (Date.now() < deadline) { last = await read(); if (last) return last; await new Promise((resolve) => setTimeout(resolve, 1_000)); } if (last) return last; throw new Error(`Timed out waiting for ${what} to be created after settlement`); } // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- export const test = base.extend({ // eslint-disable-next-line no-empty-pattern -- backend-only fixture; Playwright requires the fixtures arg. costData: async ({}, use) => { const data = await seedCostedReceivedItem(); await use(data); }, }); export { expect };