# Create Web Service

In this section you'll build the `sendMO` endpoint: a WebDev `doPost` resource that receives a payload from the ERP simulator and passes the request body to a shared script function, `trainingERP.sendMO(body)`, which validates the order, creates or updates it, and schedules it. In the next section you'll test the flow end to end.

::::action[Install the Ignition WebDev Module]

:::info[Reference]
[The Ignition WebDev Module](https://docs.inductiveautomation.com/docs/8.3/ignition-modules/web-dev)
:::

:::note
If you haven't already installed WebDev on your gateway, you'll need to do that first. Otherwise, you can skip this step.
:::

1. Navigate to [Ignition Downloads](https://inductiveautomation.com/downloads/ignition)
2. Select the version of Ignition you are running
3. Scroll down to the Ignition Modules section and download the **WebDev Module**
4. Install the module
::::

:::action[Create the `sendMO` WebDev endpoint]
1. In the Ignition Designer, open the KANOA_SCRIPTS project. This is where the WebDev resource will be created.
2. Navigate to the **WebDev** section.
3. Create a new folder named `api`.
4. Inside that folder, create a new Python resource named `sendMO`.
5. Configure the resource:
   - **HTTP method:** `doPost`
   - **Enabled:** Yes

6. Copy the code block below into the `doPost` resource:

<details>
<summary>doPost code block</summary>

```python title="WebDev doPost"
	'''
	    This function....
	    1. Reads the request headers.
	    2. Reads the raw request body.
	    3. Passes the body to `trainingERP.sendMO`.
	    4. Catches unexpected errors.
	    5. Returns a JSON response to the caller.
	'''
	
	headers = request.get("headers", {}) # Get headers safely
	content_type = (headers.get("Content-Type") or headers.get("content-type") or "").lower()
	
	body = request.get("data", b"") # Get raw request body
	if body is None: body = b""
	
	try:
	    status, msg = trainingERP.sendMO(body)
	except:
	    return {
	        "status": 400,
	        "contentType": "application/json",
	        "response": system.util.jsonEncode({"ok": False, "msg": "Unknown error"})
	    }
	
	return {
	    "status": 200,
	    "contentType": "application/json",
	    "response": system.util.jsonEncode({"ok": status, "msg": msg})
	}
```
</details>

This creates the endpoint that the ERP simulator calls. WebDev resources are served at `http://<gateway>:8088/system/webdev/<project>/<path>`, and because `kanoaMES` inherits from KANOA_SCRIPTS, the endpoint is also available through it. The ERP simulator calls `http://localhost:8088/system/webdev/kanoaMES/api/sendMO`.
:::

:::action[Create the sendMO function]

The endpoint should handle only HTTP-specific concerns: the request, the response, the status code, the content type, and basic exception handling. 
The actual business logic will live in a project script library, which makes it easier to test, reuse, maintain, version, and call from other workflows if needed.

1. In the KANOA_SCRIPTS project, create a script library called `trainingERP`.
2. Copy the code block below into the `trainingERP` script library.

<details>
<summary>trainingERP code block</summary>

```python title="trainingERP (project library)"
import java.lang

#################################################################################
def updateWOTag(message, update):
	"""
	Demo-only helper for the ERP simulator screen.
	"""
	tagPaths = ['[Kanoa]erpSim/message', '[Kanoa]erpSim/woUpdate']
	values = [message, update]
	system.tag.writeBlocking(tagPaths, values)

	return
#################################################################################
def validateRequiredFields(orderInfo):
	"""
	Validate the minimum fields needed to create a production order.
	"""
	requiredFields = ['manufacturingOrder', 'orderQty', 'dueDate', 'status', 'itemName']

	missingFields = [field for field in requiredFields if orderInfo.get(field) in [None, '']]
	if missingFields:
		return False, 'Missing required order fields: %s' % ', '.join(missingFields)

	return True, None
#################################################################################
def parseDate(value):
	"""
	ERP sends dates as text, e.g. '2026-05-31' or '2026-05-14 19:30'. Returns None if unreadable.
	"""
	for fmt in ['yyyy-MM-dd HH:mm', 'yyyy-MM-dd']:
		try:
			return system.date.parse(value, fmt)
		except:
			pass
	return None
#################################################################################
def removeFutureBlocks(workOrderId, userId):
	"""
	Delete this order's schedule blocks that haven't started yet.
	Returns the asset ids whose schedule changed, so their screens can be refreshed.
	"""
	assetIds = set()
	scheduleBlocks = system.kanoa.utilities.convertDatasetToJSON(system.kanoa.schedule.getScheduleBlocks({'workOrderId': workOrderId}))
	for block in scheduleBlocks:
		if block['startDate'] > system.date.now():
			system.kanoa.schedule.deleteScheduleBlock(block['scheduleBlockId'], userId)
			assetIds.add(block['assetId'])

	return assetIds
#################################################################################
def sendMO(body):
	"""
	ERP-to-Kanoa production order import with scheduling.
	Safe to resend: an order that already exists is updated, not duplicated.

	- Released orders: create anything missing (unit, order source, item, rate),
	  add or update the order, and schedule it on the asset whose erpName matches
	- Scheduled orders are set to the 'Scheduled' status, if you've created one
	- Closed / Completed orders: set the status and remove future schedule blocks
	- Other statuses (e.g. Hold): update an existing order's status
	"""
	log = system.util.getLogger('kanoa_ERP_IF')
	utils = system.kanoa.utilities
	woName = None

	try:
		# WebDev passes a dict for JSON requests. Accept a JSON string too.
		if isinstance(body, basestring):
			body = system.util.jsonDecode(body)
		log.info('Received sendMO() POST with %s' % body)

		orderInfo = body.get('order') if body else None
		if not orderInfo: return False, 'No order information found'

		isValid, validationMsg = validateRequiredFields(orderInfo)
		if not isValid:
			log.warn(validationMsg)
			return False, validationMsg

		woName = orderInfo['manufacturingOrder'].strip()
		woStatus = orderInfo['status']
		userId = system.kanoa.security.getIDPUserId({'userName': 'SYSTEM'})

		# Look up IDs by name. Database IDs can differ between systems, so never hardcode them.
		woStates = utils.convertDatasetToDict(system.kanoa.order.getProductionOrderStates({}), 'workOrderStatusName', 'workOrderStatusId')
		woInfo = utils.convertDatasetRowToJSON(system.kanoa.order.getProductionOrders({'workOrderName': woName}), 0)

		# 1. Any status other than Released only changes an order we already have.
		if woStatus != 'Released':
			if not woInfo['workOrderId'] or woStatus not in woStates:
				msg = 'Order %s ignored. Status: %s' % (woName, woStatus)
				log.info(msg)
				return True, msg

			system.kanoa.order.updateProductionOrderField(woInfo['workOrderId'], 'workOrderStatusId', woStates[woStatus], userId)
			msg = 'Order %s set to %s' % (woName, woStatus)
			if woStatus in ['Closed', 'Completed']:
				for assetId in removeFutureBlocks(woInfo['workOrderId'], userId):
					system.kanoa.schedule.updateClientsScheduleChange(utils.getFieldValue('assetPath', system.kanoa.asset.getAssets({'assetId': assetId})))
				msg += ' and future schedule blocks removed'

			log.info(msg)
			updateWOTag(msg, True)
			return True, msg

		# 2. Released: create anything the order needs that doesn't exist yet.
		reqdQty = float(orderInfo['orderQty'])
		uom = (orderInfo.get('uom') or 'units').lower()
		itemUnitId = utils.getFieldValue('itemUnitId', system.kanoa.item.getItemUnits({'itemUnitName': uom}))
		if itemUnitId is None:
			itemUnitId, unitMsg = system.kanoa.item.addItemUnit({'itemUnitName': uom, 'enabled': True}, userId)

		workOrderSourceId = utils.getFieldValue('workOrderSourceId', system.kanoa.order.getProductionOrderSource({'workOrderSourceName': 'ERP'}))
		if workOrderSourceId is None:
			workOrderSourceId, sourceMsg = system.kanoa.order.addProductionOrderSource({'workOrderSourceName': 'ERP', 'enabled': True}, userId)

		# orderType arrives as e.g. 'PRODUCTION'. Fall back to the Production mode.
		modeId = utils.getFieldValue('modeId', system.kanoa.asset.getModes({'modeName': (orderInfo.get('orderType') or '').title()}))
		if modeId is None:
			modeId = utils.getFieldValue('modeId', system.kanoa.asset.getModes({'modeName': 'Production'}))

		itemName = orderInfo['itemName'].strip()
		itemDescription = orderInfo.get('itemDescription')
		itemClassPath = orderInfo.get('itemClassPath')
		itemClassId = system.kanoa.item.checkItemClassPath(itemClassPath, userId) if itemClassPath else None
		itemInfo = utils.convertDatasetRowToJSON(system.kanoa.item.getItems({'itemName': itemName}), 0)
		if itemInfo['itemId'] is None:
			log.info('Adding new item %s to %s' % (itemName, itemClassPath))
			itemInfo['itemId'] = system.kanoa.item.addItem({'itemName': itemName, 'itemDescription': itemDescription, 'itemClassId': itemClassId, 'itemColor': None, 'enabled': True}, userId)
		elif itemDescription and itemDescription != itemInfo['itemDescription']:
			system.kanoa.item.updateItemField(itemInfo['itemId'], 'itemDescription', itemDescription, userId)

		# 3. Find the asset to schedule on: the one whose erpName attribute matches scheduleInfo.assetName.
		scheduleInfo = orderInfo.get('scheduleInfo') or {}
		assetId = None
		if scheduleInfo.get('enabled') and scheduleInfo.get('assetName'):
			assetId = utils.getFieldValue('assetId', system.kanoa.asset.getAssetAttributes({'assetAttrName': 'erpName', 'assetAttrValue': scheduleInfo['assetName'].strip()}))
			if assetId is None:
				log.warn('No asset has erpName %s. Order %s will not be scheduled' % (scheduleInfo['assetName'], woName))

		# 4. Add or update the production order.
		newInfo = {
			'workOrderName': woName, 'modeId': modeId, 'itemId': itemInfo['itemId'], 'itemUnitId': itemUnitId,
			'assetId': assetId, 'workOrderSourceId': workOrderSourceId, 'reqdQty': reqdQty,
			'dueDate': parseDate(orderInfo['dueDate']), 'workOrderStatusId': woStates['Released'], 'note': orderInfo.get('notes')
		}
		if assetId and woStates.get('Scheduled'):
			newInfo['workOrderStatusId'] = woStates['Scheduled']	# optional: only if a 'Scheduled' status exists
		if woInfo['workOrderId']:
			newInfo['workOrderId'] = woInfo['workOrderId']
			if woInfo.get('workOrderStatusName') == 'In Progress':
				newInfo['workOrderStatusId'] = woStates['In Progress']	# don't send a running order back to Released
			system.kanoa.order.updateProductionOrder(newInfo, userId)
			msg = 'Order %s update received from ERP' % woName
		else:
			newInfo['workOrderId'] = system.kanoa.order.addProductionOrder(newInfo, userId)
			msg = 'New order %s has been received from ERP' % woName
		woId = newInfo['workOrderId']

		# 5. Schedule it. Replace blocks that haven't started, so a resend moves the order.
		changedAssets = removeFutureBlocks(woId, userId)
		if assetId:
			rateInfo = scheduleInfo.get('rateInfo') or {}
			period = (rateInfo.get('period') or 'hour').title()
			rate = utils.convertDatasetRowToJSON(system.kanoa.item.getAssetItems({'assetId': assetId, 'itemId': itemInfo['itemId']}), 0)
			if rate.get('itemId') is None and rateInfo.get('equipmentRate'):
				# No rate for this item on this asset yet: add one from what ERP sent.
				rate = {
					'itemId': itemInfo['itemId'], 'assetId': assetId,
					'standardRate': float(rateInfo['equipmentRate']), 'scheduleRate': float(rateInfo.get('budgetRate') or rateInfo['equipmentRate']),
					'itemPeriodId': utils.getFieldValue('itemPeriodId', system.kanoa.item.getItemPeriod({'itemPeriodName': period})),
					'itemSourceId': utils.getFieldValue('itemSourceId', system.kanoa.item.getItemSource({'itemSourceName': 'output'})),
					'infeedUnitId': itemUnitId, 'outfeedUnitId': itemUnitId, 'wasteUnitId': itemUnitId, 'packageCount': 1.0, 'enabled': True
				}
				system.kanoa.item.addItemAssetRate(rate, userId)
				rate['itemPeriodName'] = period

			# Block length = quantity / scheduled rate. Without a rate, schedule 6 hours.
			start = parseDate(scheduleInfo.get('startDate')) or system.date.now()
			ratePerMinute = rate.get('scheduleRate') or rate.get('standardRate') or 0
			if rate.get('itemPeriodName') == 'Hour':
				ratePerMinute = ratePerMinute / 60.0
			end = system.date.addMinutes(start, int(reqdQty / ratePerMinute)) if ratePerMinute else system.date.addHours(start, 6)

			scheduleBlockInfo = {'scheduleBlockName': 'Manufacturing Order', 'assetId': assetId, 'itemId': itemInfo['itemId'], 'workOrderId': woId,
				'scheduledQty': reqdQty, 'modeId': modeId, 'startDate': start, 'endDate': end, 'notes': None, 'rruleStr': None, 'color': None}
			system.kanoa.schedule.addScheduleBlock(scheduleBlockInfo, userId)
			changedAssets.add(assetId)
			msg += ' and scheduled'

		for changedAssetId in changedAssets:
			system.kanoa.schedule.updateClientsScheduleChange(utils.getFieldValue('assetPath', system.kanoa.asset.getAssets({'assetId': changedAssetId})))

		# 6. Save what ERP sent as order metadata: the order as apiBody, plus each metaData entry (e.g. machineSetpoints).
		metaData = dict(body.get('metaData') or {})
		metaData['apiBody'] = orderInfo
		existingMeta = utils.convertDatasetToDict(system.kanoa.order.getProductionOrderMetaData({'workOrderId': woId}), 'metaDataName', 'workOrderMetaDataId')
		for name, value in metaData.items():
			metaInfo = {'workOrderId': woId, 'metaDataName': name, 'metaDataValue': system.util.jsonEncode(value)}
			if existingMeta.get(name):
				metaInfo['workOrderMetaDataId'] = existingMeta[name]
				system.kanoa.order.updateProductionOrderMetaData(metaInfo, userId)
			else:
				system.kanoa.order.addProductionOrderMetaData(metaInfo, userId)

		log.info('%s (workOrderId %s)' % (msg, woId))
		updateWOTag(msg, True)	# demo only: ERP simulator feedback
		return True, msg

	except java.lang.Exception, e:
		msg = 'Java Exception: Failed to add ERP order %s. %s' % (woName or '<unknown>', e)
		log.error(msg)
		return False, msg
	except Exception, e:
		msg = 'Exception: Failed to add ERP order %s. %s' % (woName or '<unknown>', e)
		log.error(msg)
		return False, msg
```

</details>

:::
