417 lines
22 KiB
JavaScript
417 lines
22 KiB
JavaScript
'use strict';
|
|
// routes/batchIntimations.js — "Batch Issuance Intimation" (data sourced from Requirements)
|
|
const express = require('express');
|
|
const router = express.Router();
|
|
const { verifyToken } = require('../middleware/auth');
|
|
const { getPool } = require('../services/sqlPool');
|
|
|
|
const store = () => require('../services/batchIntimationStore');
|
|
const notify = () => require('../services/notifyStore');
|
|
const workOrderStore = () => require('../services/workOrderStore');
|
|
const requirementStore = () => require('../services/requirementStore');
|
|
const appSettings = require('../services/appSettingsStore');
|
|
|
|
// Server-side enforcement of "batch total can't exceed the requirement's
|
|
// pending qty" — the frontend already blocks this on CREATE (using pendingQty
|
|
// supplied by GET /requirements, which already nets out every existing
|
|
// intimation), but that guard was skipped entirely in Edit mode and easy to
|
|
// bypass anyway since it's client-side only. `excludeId` (the intimation
|
|
// being edited) makes sure THIS document's own already-saved qty doesn't
|
|
// count against its own pending total.
|
|
async function checkQtyWithinPending(clean, requirementId, refNo, company, excludeId) {
|
|
let reqDoc = requirementId ? await requirementStore().findById(requirementId) : null;
|
|
if (!reqDoc && refNo) {
|
|
const all = await requirementStore().listRequirements({ company });
|
|
reqDoc = all.find(r => r.refNo === refNo) || null;
|
|
}
|
|
if (!reqDoc) return null; // requirement no longer resolvable — nothing to validate against
|
|
const reqByItem = {};
|
|
(reqDoc.lines || []).forEach(l => { reqByItem[l.itemCode] = Number(l.requiredQty) || 0; });
|
|
const intMap = await store().intimatedByRequirement({ company, excludeId });
|
|
const info = intMap['rid:' + reqDoc.id] || intMap['ref:' + reqDoc.refNo] || { byItem: {} };
|
|
for (const p of clean) {
|
|
const reqQty = reqByItem[p.itemCode];
|
|
if (reqQty == null) continue; // item isn't on this requirement — nothing to cap against
|
|
const doneElsewhere = Number(info.byItem[p.itemCode]) || 0;
|
|
const thisDocQty = p.batches.reduce((s, b) => s + (Number(b.batchSize) || 0), 0);
|
|
const pending = Math.max(reqQty - doneElsewhere, 0);
|
|
if (thisDocQty > pending + 1e-9)
|
|
return `${p.itemCode}: batch total ${thisDocQty} exceeds pending ${pending} (required ${reqQty}, already intimated elsewhere ${doneElsewhere})`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Does this batch number already exist in SAP at all (any item, regardless
|
|
// of current stock — OBTN is the batch MASTER table, unlike OIBT which only
|
|
// has batches with stock on hand)? Mirrors GET /api/sap/lookup/batch-exists;
|
|
// duplicated here (rather than an HTTP self-call) so the "Batch No. Required"
|
|
// server-side check stays a single request.
|
|
async function batchExistsInSap(batchNo, company) {
|
|
const needle = (batchNo || '').trim().replace(/'/g, "''");
|
|
if (!needle) return null;
|
|
try {
|
|
const pool = await getPool(company);
|
|
const r = await pool.request().query(`SELECT TOP 1 "ItemCode" FROM [dbo].[OBTN] WHERE "DistNumber"='${needle}'`);
|
|
return r.recordset[0] || null;
|
|
} catch (e) { console.warn('[BII] batchExistsInSap failed:', e.message); return null; }
|
|
}
|
|
|
|
// Which of the given item codes SAP itself tracks by batch (OITM.ManBtchNum
|
|
// = 'Y')? Item-wise "Batch No. Required" is auto-derived from this — never a
|
|
// manual per-document toggle — so it can never disagree with what SAP will
|
|
// actually accept. Mirrors GET /api/sap/batch-managed-items.
|
|
async function batchManagedItemCodes(itemCodes, company) {
|
|
const codes = [...new Set(itemCodes)].filter(Boolean);
|
|
if (!codes.length) return new Set();
|
|
try {
|
|
const pool = await getPool(company);
|
|
const list = codes.map(c => `'${c.replace(/'/g, "''")}'`).join(',');
|
|
const r = await pool.request().query(`SELECT "ItemCode" FROM [dbo].[OITM] WHERE "ItemCode" IN (${list}) AND "ManBtchNum"='Y'`);
|
|
return new Set(r.recordset.map(x => x.ItemCode));
|
|
} catch (e) { console.warn('[BII] batchManagedItemCodes failed:', e.message); return new Set(); }
|
|
}
|
|
|
|
// Server-side "Batch No. Required" enforcement, ITEM-WISE: only items SAP
|
|
// itself tracks by batch require Batch No./MFG/EXP; every batch row with a
|
|
// Batch No. entered (required or not) is still checked for uniqueness
|
|
// against every OTHER saved Intimation and against SAP itself. Returns an
|
|
// error string, or null if everything checks out. `excludeId` lets an edit
|
|
// ignore its own row.
|
|
async function checkBatchNoRequired(clean, company, excludeId) {
|
|
const managedSet = await batchManagedItemCodes(clean.map(p => p.itemCode), company);
|
|
const seen = new Map(); // batchNo (upper) -> itemCode, to catch dupes WITHIN this submission
|
|
for (const p of clean) {
|
|
const required = managedSet.has(p.itemCode);
|
|
for (const b of p.batches) {
|
|
if (required) {
|
|
if (!b.batchNo) return `${p.itemCode}: Batch No. is required (SAP tracks this item by batch)`;
|
|
if (!b.mfgDate) return `${p.itemCode}: MFG Date is required (SAP tracks this item by batch)`;
|
|
if (!b.expDate) return `${p.itemCode}: EXP Date is required (SAP tracks this item by batch)`;
|
|
}
|
|
if (b.batchNo) {
|
|
const key = b.batchNo.toUpperCase();
|
|
if (seen.has(key)) return `Duplicate Batch No. "${b.batchNo}" used for both ${seen.get(key)} and ${p.itemCode} in this document`;
|
|
seen.set(key, p.itemCode);
|
|
}
|
|
}
|
|
}
|
|
for (const [batchNo, itemCode] of seen) {
|
|
const usedHere = await store().findBatchNoUsage(batchNo, excludeId);
|
|
if (usedHere) return `Batch No. "${batchNo}" is already used in intimation ${usedHere.docNo} (${usedHere.itemCode})`;
|
|
const usedInSap = await batchExistsInSap(batchNo, company);
|
|
if (usedInSap) return `Batch No. "${batchNo}" already exists in SAP (item ${usedInSap.ItemCode})`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Once a (non-deleted) Work Order has been generated FROM one of an
|
|
// Intimation's (product, batch) lines, THAT specific line is locked —
|
|
// editing/removing it afterwards would silently drift out of sync with the
|
|
// Work Order that already copied a snapshot of its data at creation time.
|
|
// Deleting the WHOLE Intimation document stays blocked as long as ANY line
|
|
// is locked (routes below); editing only blocks the locked lines
|
|
// themselves — see batchKey()/lockedLineViolation(). Returns the list of
|
|
// {id, woNo, productCode, batchNumber} Work Orders referencing it (empty =
|
|
// nothing locked at all).
|
|
// A REJECTED Work Order doesn't count as "linked" for locking purposes — it
|
|
// never reached Production Order/SAP issuance, so there's nothing for the
|
|
// Intimation to silently drift out of sync with. Without this, correcting a
|
|
// wrong Product/batch on the Intimation (the actual fix path once the WO
|
|
// that was generated from it gets rejected) was impossible: the line stayed
|
|
// locked forever even though the WO built from it was dead.
|
|
async function linkedWorkOrders(intimationId) {
|
|
const wos = await workOrderStore().listWorkOrders({});
|
|
return wos
|
|
.filter(w => !w.isDeleted && w.status !== 'REJECTED' && String(w.intimationId) === String(intimationId))
|
|
.map(w => ({ id: w.id, woNo: w.woNo, productCode: w.productCode || '', batchNumber: w.batchNumber || '' }));
|
|
}
|
|
|
|
// Same key convention the frontend's captureStatus()/startEdit() use.
|
|
function batchKey(itemCode, batchNo) { return `${itemCode || ''}|${batchNo || ''}`; }
|
|
|
|
// Fields that must stay byte-identical on a locked line — anything a
|
|
// generated Work Order could have copied a snapshot of.
|
|
const LOCKED_BATCH_FIELDS = ['batchNo', 'mfgDate', 'expDate', 'market', 'batchSize', 'batchVolume'];
|
|
|
|
// Compares the submitted product/batch list against what's currently stored,
|
|
// for ONLY the lines a Work Order already exists for. Returns a clear error
|
|
// message on the first violation found (removed, or any field changed), or
|
|
// null if every locked line is present and untouched — everything else in
|
|
// the document (other batches, whole new products, non-locked edits) is
|
|
// left free to change by design.
|
|
function lockedLineViolation(existingProducts, submittedProducts, linked) {
|
|
const lockedKeys = new Set(linked.map(l => batchKey(l.productCode, l.batchNumber)));
|
|
if (!lockedKeys.size) return null;
|
|
|
|
const indexBatches = (products) => {
|
|
const map = new Map();
|
|
(products || []).forEach(p => (p.batches || []).forEach(b => {
|
|
const key = batchKey(p.itemCode, b.batchNo);
|
|
if (lockedKeys.has(key)) map.set(key, b);
|
|
}));
|
|
return map;
|
|
};
|
|
const before = indexBatches(existingProducts);
|
|
const after = indexBatches(submittedProducts);
|
|
|
|
for (const key of lockedKeys) {
|
|
const [itemCode, batchNo] = key.split('|');
|
|
const prev = before.get(key);
|
|
const next = after.get(key);
|
|
if (!prev) continue; // shouldn't happen (a WO exists but the line is gone from the stored doc already) — nothing to compare against
|
|
if (!next)
|
|
return `Cannot remove ${itemCode} batch "${batchNo}" — a Work Order has already been generated from it.`;
|
|
const changedField = LOCKED_BATCH_FIELDS.find(f => String(prev[f] ?? '') !== String(next[f] ?? ''));
|
|
if (changedField)
|
|
return `Cannot change ${itemCode} batch "${batchNo}" (${changedField}) — a Work Order has already been generated from it.`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// MFG/EXP accept any of 6 formats (empty allowed) — same set as the
|
|
// picker-only date fields in public/work-order.html, public/batch-issuance.html
|
|
// and public/receipt-production.html: DD-MMM-YYYY, DD-MM-YYYY, MMM-YYYY,
|
|
// MM-YYYY, YYYY-MMM, YYYY-MM. Kept in sync with those — this backend check
|
|
// must never fall behind the frontend's accepted formats again.
|
|
const DATE_RES = [
|
|
/^(\d{1,2})[-/]([A-Za-z]{3})[-/](\d{4})$/, // DD-MMM-YYYY
|
|
/^(\d{1,2})-(\d{1,2})-(\d{4})$/, // DD-MM-YYYY
|
|
/^([A-Za-z]{3})[-/](\d{4})$/, // MMM-YYYY
|
|
/^(\d{1,2})-(\d{4})$/, // MM-YYYY
|
|
/^(\d{4})-([A-Za-z]{3})$/, // YYYY-MMM
|
|
/^(\d{4})-(\d{1,2})$/, // YYYY-MM
|
|
];
|
|
function isValidMEDate(v) { const t = String(v || '').trim(); return !t || DATE_RES.some(re => re.test(t)); }
|
|
function badDates(products) {
|
|
const bad = [];
|
|
(products || []).forEach(p => (p.batches || []).forEach(b => {
|
|
if (b.mfgDate && !isValidMEDate(b.mfgDate)) bad.push(`${p.itemCode} MFG "${b.mfgDate}"`);
|
|
if (b.expDate && !isValidMEDate(b.expDate)) bad.push(`${p.itemCode} EXP "${b.expDate}"`);
|
|
}));
|
|
return bad;
|
|
}
|
|
|
|
function normProducts(products) {
|
|
const clean = [];
|
|
(products || []).forEach(p => {
|
|
const itemCode = (p.itemCode || '').trim();
|
|
if (!itemCode) return;
|
|
const batches = (p.batches || [])
|
|
.filter(b => (b.batchNo || '').trim() || b.batchSize || b.mfgDate || b.expDate || (b.market || '').trim())
|
|
.map(b => ({
|
|
batchNo: (b.batchNo || '').trim(),
|
|
mfgDate: b.mfgDate || null,
|
|
expDate: b.expDate || null,
|
|
market: (b.market || '').trim(),
|
|
batchSize: b.batchSize === '' || b.batchSize == null ? null : Number(b.batchSize),
|
|
// Batch Size (Volume, Ltr) — feeds Work Order's Solution Batch Size
|
|
// when a WO is generated from this batch (see pickBatch() in
|
|
// work-order.html). Distinct from batchSize (a quantity/"Total Units").
|
|
batchVolume: b.batchVolume === '' || b.batchVolume == null ? null : Number(b.batchVolume),
|
|
}));
|
|
clean.push({
|
|
itemCode,
|
|
itemName: (p.itemName || '').trim(),
|
|
itemDesc: (p.itemDesc || '').trim(),
|
|
requiredQty: p.requiredQty === '' || p.requiredQty == null ? null : Number(p.requiredQty),
|
|
issueDate: p.issueDate || null,
|
|
batches,
|
|
});
|
|
});
|
|
return clean;
|
|
}
|
|
|
|
// List intimations
|
|
router.get('/', verifyToken, async (req, res) => {
|
|
try {
|
|
const { mine, company, status, refNo } = req.query;
|
|
const data = await store().listIntimations({
|
|
mine: mine === '1' ? req.user.username : undefined,
|
|
company, status, refNo,
|
|
});
|
|
res.json({ success: true, data });
|
|
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
|
|
});
|
|
|
|
// Which Work Order(s), if any, were generated from this Intimation — used by
|
|
// the frontend to lock Edit/Delete once a Work Order exists.
|
|
router.get('/:id/linked-work-orders', verifyToken, async (req, res) => {
|
|
try {
|
|
res.json({ success: true, data: await linkedWorkOrders(req.params.id) });
|
|
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
|
|
});
|
|
|
|
// Same, but for every intimation at once (one query) — used by the list view
|
|
// so each card can show its locked state without an N+1 fetch.
|
|
router.get('/linked-work-orders/all', verifyToken, async (req, res) => {
|
|
try {
|
|
const wos = await workOrderStore().listWorkOrders({});
|
|
const map = {}; // non-rejected only — whole-row/whole-document lock (Edit/Delete gating)
|
|
const allMap = {}; // every non-deleted status, REJECTED included — used only to keep a
|
|
// batch's own Batch No. (and its row) from being changed/removed once
|
|
// ANY Work Order, even a rejected one awaiting edit+resubmit, already
|
|
// references it by that number. Without this, correcting other fields
|
|
// on a rejected batch's row (which IS meant to stay editable) could also
|
|
// let the Batch No. itself drift, silently breaking the (productCode,
|
|
// batchNumber) link back to that rejected Work Order.
|
|
wos.forEach(w => {
|
|
if (!w.intimationId || w.isDeleted) return;
|
|
const k = String(w.intimationId);
|
|
const entry = { id: w.id, woNo: w.woNo, productCode: w.productCode || '', batchNumber: w.batchNumber || '' };
|
|
(allMap[k] || (allMap[k] = [])).push(entry);
|
|
if (w.status === 'REJECTED') return;
|
|
(map[k] || (map[k] = [])).push(entry);
|
|
});
|
|
res.json({ success: true, data: map, allData: allMap });
|
|
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
|
|
});
|
|
|
|
// Live "is this batch number already used?" check — against other saved
|
|
// Intimations AND SAP itself (OBTN). Used by the Create form to give
|
|
// immediate feedback as the user types, ahead of the authoritative check
|
|
// that also runs server-side on submit. Placed before GET /:id so "usage"
|
|
// isn't swallowed as an :id param.
|
|
router.get('/batch-no-usage', verifyToken, async (req, res) => {
|
|
try {
|
|
const { batchNo, excludeId, company } = req.query;
|
|
if (!batchNo) return res.json({ success: true, used: false });
|
|
const local = await store().findBatchNoUsage(batchNo, excludeId);
|
|
if (local) return res.json({ success: true, used: true, where: 'intimation', ...local });
|
|
const sap = await batchExistsInSap(batchNo, company);
|
|
if (sap) return res.json({ success: true, used: true, where: 'sap', itemCode: sap.ItemCode });
|
|
res.json({ success: true, used: false });
|
|
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
|
|
});
|
|
|
|
// Get one
|
|
router.get('/:id', verifyToken, async (req, res) => {
|
|
try {
|
|
const r = await store().findById(req.params.id);
|
|
if (!r || r.isDeleted) return res.status(404).json({ success: false, message: 'Not found' });
|
|
res.json({ success: true, data: r });
|
|
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
|
|
});
|
|
|
|
// Create
|
|
router.post('/', verifyToken, async (req, res) => {
|
|
try {
|
|
const { refNo, requirementId, date, issueDate, remarks, products, company } = req.body || {};
|
|
if (!refNo) {
|
|
// No Requirement given — only acceptable via the "Without Intimation"
|
|
// tab (Admin → System Settings → "Batch Issuance → Enable SFG
|
|
// Workflow"). Any item is allowed through that tab — no per-item
|
|
// classification check.
|
|
if (!appSettings.biSfgWorkflowEnabled())
|
|
return res.status(400).json({ success: false, message: 'Requirement Ref No is required' });
|
|
}
|
|
|
|
// Hard gate (Admin → System Settings → "Requirement — Store Review
|
|
// Workflow" — both the whole-feature switch AND its own hard-gate
|
|
// toggle must be ON): a Batch Intimation can't be raised from a
|
|
// Requirement that hasn't completed the Production↔Store review round
|
|
// trip yet (REVIEW_STAGE 2). Off by default — most sites never see this.
|
|
if (appSettings.requirementStoreReviewEnabled() && appSettings.requirementStoreReviewHardGate()) {
|
|
let reqDoc = requirementId ? await requirementStore().findById(requirementId) : null;
|
|
if (!reqDoc && refNo) {
|
|
const all = await requirementStore().listRequirements({ company });
|
|
reqDoc = all.find(r => r.refNo === refNo) || null;
|
|
}
|
|
if (reqDoc && reqDoc.reviewStage !== 2)
|
|
return res.status(409).json({ success: false, message: `${reqDoc.refNo} hasn't completed the Store review yet — it must be shared with Store and reverted back to Production first (currently ${reqDoc.reviewStage === 1 ? 'awaiting Store review' : 'not yet shared'}).` });
|
|
}
|
|
|
|
const clean = normProducts(products);
|
|
if (!clean.length)
|
|
return res.status(400).json({ success: false, message: 'No products to submit' });
|
|
if (!clean.some(p => p.batches.length))
|
|
return res.status(400).json({ success: false, message: 'Add at least one batch' });
|
|
const noIssueDate = clean.find(p => p.batches.length && !p.issueDate);
|
|
if (noIssueDate)
|
|
return res.status(400).json({ success: false, message: `${noIssueDate.itemCode}: Issue Date is required` });
|
|
const bad = badDates(clean);
|
|
if (bad.length)
|
|
return res.status(400).json({ success: false, message: 'Invalid MFG/EXP date (use DD-MMM-YYYY or MMM/YYYY): ' + bad.join(', ') });
|
|
// Item-wise, auto-derived from SAP — not a manual toggle. See checkBatchNoRequired().
|
|
const batchErr = await checkBatchNoRequired(clean, company, null);
|
|
if (batchErr) return res.status(400).json({ success: false, message: batchErr });
|
|
if (!appSettings.biAllowExceedPending()) {
|
|
const qtyErr = await checkQtyWithinPending(clean, requirementId, refNo, company, null);
|
|
if (qtyErr) return res.status(400).json({ success: false, message: qtyErr });
|
|
}
|
|
|
|
const saved = await store().insertIntimation({
|
|
refNo, requirementId, date: date || null, issueDate: issueDate || null,
|
|
remarks: remarks || '', products: clean, company: company || '',
|
|
createdBy: req.user.username, createdByName: req.user.name || req.user.username,
|
|
});
|
|
res.json({ success: true, data: saved });
|
|
// No approval-step workflow on this module (see services/batchIntimationStore.js)
|
|
// — the "concerned user" is whoever holds the Batch Issuance sidebar module.
|
|
notify().notify({
|
|
moduleKey: 'production-batch-issuance',
|
|
title: `Batch Issuance Intimation ${saved.refNo} — New Intimation`,
|
|
lines: [['Ref No', saved.refNo], ['Products', clean.length], ['Created By', saved.createdByName]],
|
|
url: `${process.env.APP_BASE_URL || ''}/batch-issuance`,
|
|
excludeUsernames: [req.user.username],
|
|
});
|
|
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
|
|
});
|
|
|
|
// Modify
|
|
router.put('/:id', verifyToken, async (req, res) => {
|
|
try {
|
|
const existing = await store().findById(req.params.id);
|
|
if (!existing || existing.isDeleted)
|
|
return res.status(404).json({ success: false, message: 'Not found' });
|
|
const linked = await linkedWorkOrders(req.params.id);
|
|
|
|
const { date, issueDate, remarks, products, company } = req.body || {};
|
|
const clean = normProducts(products);
|
|
if (!clean.length) return res.status(400).json({ success: false, message: 'No products to submit' });
|
|
// Lines a Work Order has already been generated from must stay exactly
|
|
// as they are — everything else in the document can be freely edited,
|
|
// added, or removed. See lockedLineViolation()'s own doc comment.
|
|
const lockViol = lockedLineViolation(existing.products, clean, linked);
|
|
if (lockViol) return res.status(409).json({ success: false, message: lockViol });
|
|
const noIssueDate = clean.find(p => p.batches.length && !p.issueDate);
|
|
if (noIssueDate)
|
|
return res.status(400).json({ success: false, message: `${noIssueDate.itemCode}: Issue Date is required` });
|
|
const bad = badDates(clean);
|
|
if (bad.length)
|
|
return res.status(400).json({ success: false, message: 'Invalid MFG/EXP date (use DD-MMM-YYYY or MMM/YYYY): ' + bad.join(', ') });
|
|
const batchErr = await checkBatchNoRequired(clean, company || existing.company, req.params.id);
|
|
if (batchErr) return res.status(400).json({ success: false, message: batchErr });
|
|
if (!appSettings.biAllowExceedPending()) {
|
|
const qtyErr = await checkQtyWithinPending(clean, existing.requirementId, existing.refNo, company || existing.company, req.params.id);
|
|
if (qtyErr) return res.status(400).json({ success: false, message: qtyErr });
|
|
}
|
|
|
|
const updated = await store().updateIntimation(req.params.id, {
|
|
date: date || null, issueDate: issueDate || null, remarks: remarks || '', products: clean,
|
|
});
|
|
res.json({ success: true, data: updated });
|
|
notify().notify({
|
|
moduleKey: 'production-batch-issuance',
|
|
title: `Batch Issuance Intimation ${updated.refNo} — Updated`,
|
|
lines: [['Ref No', updated.refNo], ['Products', clean.length], ['Updated By', req.user.name || req.user.username]],
|
|
url: `${process.env.APP_BASE_URL || ''}/batch-issuance`,
|
|
excludeUsernames: [req.user.username],
|
|
});
|
|
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
|
|
});
|
|
|
|
// Soft delete
|
|
router.delete('/:id', verifyToken, async (req, res) => {
|
|
try {
|
|
const existing = await store().findById(req.params.id);
|
|
if (!existing) return res.status(404).json({ success: false, message: 'Not found' });
|
|
const linked = await linkedWorkOrders(req.params.id);
|
|
if (linked.length)
|
|
return res.status(409).json({ success: false, message: `Cannot delete — a Work Order has already been generated from this Intimation (${linked.map(l => l.woNo).join(', ')}).` });
|
|
await store().softDelete(req.params.id);
|
|
res.json({ success: true });
|
|
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
|
|
});
|
|
|
|
module.exports = router;
|