update product batch

This commit is contained in:
Joseph Le 2026-02-10 20:03:36 +11:00
parent 81a1448ed3
commit 8b4f817d49
7 changed files with 2042 additions and 568 deletions

262
README.md
View File

@ -0,0 +1,262 @@
# Batch Management System
A comprehensive system for managing batches with items and mixed items.
## Features
- ✅ Create batches with valid items (same MPN) and mixed items (different MPN)
- 📊 View all batches with expandable item lists
- 🔍 Search batches by name or ID
- 🗑️ Delete batches (cascade deletes all items)
- 📄 Pagination and sorting
- 🎨 Modern, responsive UI
## Database Structure
### Tables
**batches**
- `id` - Auto-increment primary key
- `batch_name` - Unique batch identifier
- `createdAt` - Timestamp
**items** (Valid items with same MPN)
- `id` - Auto-increment primary key
- `batch_id` - Foreign key to batches
- `mpn` - Manufacturer Part Number
- `sn` - Serial Number
- `createdAt` - Timestamp
**items_mix** (Mixed items with different MPN)
- `id` - Auto-increment primary key
- `batch_id` - Foreign key to batches
- `mpn` - Manufacturer Part Number
- `sn` - Serial Number
- `createdAt` - Timestamp
## Installation
1. Install dependencies:
```bash
npm install
```
2. Start the server:
```bash
npm start
```
3. For development with auto-reload:
```bash
npm run dev
```
4. Open browser to:
```
http://localhost:4444
```
## API Documentation
### 1. Save Batch
**POST** `/api/batch/save`
Create a new batch with items and optional mixed items.
**Request Body:**
```json
{
"batch_name": "BATCH_20250210141530",
"items": [
{
"mpn": "CN_M378A5143DB0-CPB_1620",
"sn": "U03A00062074544398"
},
{
"mpn": "CN_M378A5143DB0-CPB_1620",
"sn": "U03A00062074544399"
}
],
"items_mix": [
{
"mpn": "CN_M378A5143DB0-CPB_1621",
"sn": "U03A00062074544403"
}
]
}
```
**Response:**
```json
{
"success": true,
"batch_id": 1,
"batch_name": "BATCH_20250210141530",
"inserted_items": 2,
"inserted_mix_items": 1
}
```
### 2. Get All Batches
**GET** `/api/batch/get-all`
Retrieve all batches with pagination, search, and sorting.
**Query Parameters:**
- `page` (default: 1) - Page number
- `limit` (default: 50) - Items per page
- `search` - Search by batch name or ID
- `sortBy` (default: "id") - Column to sort by (id, batch_name, createdAt)
- `sortOrder` (default: "DESC") - Sort order (ASC, DESC)
**Example:**
```
GET /api/batch/get-all?page=1&limit=50&sortBy=createdAt&sortOrder=DESC
```
**Response:**
```json
{
"batches": [
{
"id": 1,
"batch_name": "BATCH_20250210141530",
"createdAt": "2025-02-10 14:15:30",
"items": [
{
"mpn": "CN_M378A5143DB0-CPB_1620",
"sn": "U03A00062074544398",
"createdAt": "2025-02-10 14:15:30"
}
],
"items_mix": [
{
"mpn": "CN_M378A5143DB0-CPB_1621",
"sn": "U03A00062074544403",
"createdAt": "2025-02-10 14:15:30"
}
]
}
],
"total": 1,
"page": 1,
"limit": 50,
"totalPages": 1
}
```
### 3. Get Single Batch
**GET** `/api/batch/get/:id`
Get a specific batch by ID with all its items.
**Example:**
```
GET /api/batch/get/1
```
**Response:**
```json
{
"id": 1,
"batch_name": "BATCH_20250210141530",
"createdAt": "2025-02-10 14:15:30",
"items": [...],
"items_mix": [...]
}
```
### 4. Delete Batch
**DELETE** `/api/batch/delete/:id`
Delete a batch and all its items (cascade delete).
**Example:**
```
DELETE /api/batch/delete/1
```
**Response:**
```json
{
"success": true,
"deleted": 1
}
```
### 5. Search Items
**GET** `/api/items/search`
Search for items across all batches.
**Query Parameters:**
- `q` (required) - Search query for MPN or SN
**Example:**
```
GET /api/items/search?q=U03A00062074544398
```
**Response:**
```json
{
"results": [
{
"batch_id": 1,
"batch_name": "BATCH_20250210141530",
"mpn": "CN_M378A5143DB0-CPB_1620",
"sn": "U03A00062074544398",
"createdAt": "2025-02-10 14:15:30",
"type": "items"
}
]
}
```
## Frontend Features
### Batch Table
- Click on batch row to expand and view items
- Items are displayed in two sections:
- ✅ Valid Items (same MPN)
- ⚠️ Mixed Items (different MPN)
### Create Batch Modal
- Auto-generates batch name with timestamp
- JSON input for items and mixed items
- Validation for required fields and JSON format
### Search & Sort
- Real-time search with debouncing
- Sort by ID, Batch Name, or Created At
- Toggle between ASC/DESC order
## Technologies
- **Backend:** Node.js, Express.js
- **Database:** SQLite3
- **Frontend:** Vanilla JavaScript, HTML5, CSS3
- **Features:** RESTful API, Responsive Design
## Project Structure
```
.
├── server.js # Express server with API routes
├── index.html # Frontend UI
├── package.json # Node.js dependencies
├── products.db # SQLite database (auto-generated)
└── README.md # This file
```
## Notes
- Database is automatically created on first run
- All timestamps are stored in UTC and converted to local time in UI
- Cascade delete ensures all items are removed when batch is deleted
- Foreign key constraints maintain data integrity

1365
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +1,20 @@
{
"name": "scanramproducts",
"name": "batch-management-system",
"version": "1.0.0",
"description": "",
"main": "index.js",
"description": "Batch management system with items and mixed items",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"start": "node server.js",
"dev": "nodemon server.js"
},
"keywords": [],
"keywords": ["batch", "management", "sqlite", "express"],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^5.2.1",
"sqlite3": "^5.1.7"
"express": "^4.18.2",
"sqlite3": "^5.1.6"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}

BIN
products.db Normal file

Binary file not shown.

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Product Management</title>
<title>Batch Management</title>
<style>
* {
margin: 0;
@ -19,7 +19,7 @@
}
.container {
max-width: 1400px;
max-width: 1600px;
margin: 0 auto;
background: white;
border-radius: 16px;
@ -94,6 +94,16 @@
box-shadow: 0 4px 12px rgba(44, 62, 80, 0.4);
}
.btn-success {
background: linear-gradient(135deg, #27ae60 0%, #229954 100%);
color: white;
}
.btn-success:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.4);
}
.table-container {
overflow-x: auto;
padding: 0 30px 30px;
@ -150,6 +160,100 @@
background: #f8f9fa;
}
.batch-row {
cursor: pointer;
font-weight: 500;
}
.batch-row:hover {
background: #e3f2fd !important;
}
.batch-row.expanded {
background: #e8f5e9 !important;
}
.items-row {
display: none;
background: #fafafa;
}
.items-row.show {
display: table-row;
}
.items-cell {
padding: 20px !important;
border-left: 4px solid #27ae60;
}
.items-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 15px;
}
.items-section {
background: white;
border-radius: 8px;
padding: 15px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.items-section h4 {
color: #2c3e50;
margin-bottom: 10px;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.items-section.mix h4 {
color: #e74c3c;
}
.item-card {
background: #f8f9fa;
padding: 10px;
border-radius: 6px;
margin-bottom: 8px;
border-left: 3px solid #27ae60;
font-size: 13px;
}
.items-section.mix .item-card {
border-left-color: #e74c3c;
}
.item-card .label {
color: #6c757d;
font-weight: 500;
display: inline-block;
width: 40px;
}
.item-card .value {
color: #495057;
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.badge-success {
background: #d4edda;
color: #155724;
}
.badge-warning {
background: #fff3cd;
color: #856404;
}
.delete-btn {
background: #dc3545;
color: white;
@ -166,6 +270,16 @@
transform: scale(1.05);
}
.expand-icon {
display: inline-block;
transition: transform 0.3s;
margin-right: 8px;
}
.expand-icon.expanded {
transform: rotate(90deg);
}
.pagination {
display: flex;
justify-content: center;
@ -223,7 +337,101 @@
color: #6c757d;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
padding: 30px;
border-radius: 12px;
width: 90%;
max-width: 800px;
max-height: 80vh;
overflow-y: auto;
}
.modal h2 {
margin-bottom: 20px;
color: #495057;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
color: #495057;
font-size: 14px;
font-weight: 500;
}
.form-group input, .form-group textarea {
width: 100%;
padding: 10px;
border: 2px solid #e9ecef;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
}
.form-group textarea {
min-height: 150px;
resize: vertical;
}
.modal-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.btn-secondary {
background: #6c757d;
color: white;
flex: 1;
}
.btn-secondary:hover {
background: #5a6268;
}
.help-text {
font-size: 12px;
color: #6c757d;
margin-top: 5px;
}
.json-example {
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 12px;
overflow-x: auto;
margin-top: 10px;
}
@media (max-width: 768px) {
.items-container {
grid-template-columns: 1fr;
}
.controls {
padding: 20px;
}
@ -252,163 +460,87 @@
padding: 20px;
}
}
@media (max-width: 480px) {
body {
padding: 10px;
}
.container {
border-radius: 12px;
}
.header {
padding: 20px;
}
th, td {
padding: 8px 6px;
font-size: 11px;
}
.btn {
padding: 10px 16px;
font-size: 13px;
}
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
padding: 30px;
border-radius: 12px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
}
.modal h2 {
margin-bottom: 20px;
color: #495057;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
color: #495057;
font-size: 14px;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 10px;
border: 2px solid #e9ecef;
border-radius: 6px;
font-size: 14px;
}
.modal-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.btn-secondary {
background: #6c757d;
color: white;
flex: 1;
}
.btn-secondary:hover {
background: #5a6268;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Product Management</h1>
<p>Manage your products efficiently</p>
<h1>📦 Batch Management System</h1>
<p>Manage batches with items and mixed items</p>
</div>
<div class="controls">
<div class="search-box">
<input type="text" id="searchInput" placeholder="Search by ID, MPN, SN, or creator...">
<input type="text" id="searchInput" placeholder="Search by batch name or ID...">
</div>
<button class="btn btn-primary" onclick="openCreateModal()">
Add Product
<button class="btn btn-success" onclick="openCreateModal()">
Create Batch
</button>
</div>
<div class="table-container">
<div id="loading" class="loading" style="display: none;">
Loading data...
Loading batches...
</div>
<div id="empty" class="empty" style="display: none;">
<h3>No products found</h3>
<p>Add your first product to get started!</p>
<h3>No batches found</h3>
<p>Create your first batch to get started!</p>
</div>
<table id="productTable" style="display: none;">
<table id="batchTable" style="display: none;">
<thead>
<tr>
<th onclick="sortTable('id')">ID</th>
<th onclick="sortTable('mpn')">MPN</th>
<th onclick="sortTable('sn')">SN</th>
<th onclick="sortTable('createdAt')">Created At</th>
<th onclick="sortTable('createdBy')">Created By</th>
<th>Actions</th>
<th onclick="sortTable('id')" style="width: 80px;">ID</th>
<th onclick="sortTable('batch_name')">Batch Name</th>
<th style="width: 120px;">Items</th>
<th style="width: 120px;">Mixed Items</th>
<th onclick="sortTable('createdAt')" style="width: 180px;">Created At</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody id="productBody"></tbody>
<tbody id="batchBody"></tbody>
</table>
</div>
<div class="pagination" id="pagination"></div>
</div>
<!-- Create Product Modal -->
<!-- Create Batch Modal -->
<div id="createModal" class="modal">
<div class="modal-content">
<h2>Add New Product</h2>
<h2>Create New Batch</h2>
<div class="form-group">
<label>MPN *</label>
<input type="text" id="mpnInput" placeholder="Enter MPN code">
<label>Batch Name *</label>
<input type="text" id="batchNameInput" placeholder="e.g., BATCH_20250210141530">
<div class="help-text">Format: BATCH_yyyymmddhhmmss or any unique name</div>
</div>
<div class="form-group">
<label>Serial Number (SN) *</label>
<input type="text" id="snInput" placeholder="Enter serial number">
<label>Items (Valid - Same MPN) *</label>
<textarea id="itemsInput" placeholder='Enter JSON array of items...'></textarea>
<div class="json-example">
Example:
[
{"mpn": "CN_M378A5143DB0-CPB_1620", "sn": "U03A00062074544398"},
{"mpn": "CN_M378A5143DB0-CPB_1620", "sn": "U03A00062074544399"}
]</div>
</div>
<div class="form-group">
<label>Created By *</label>
<input type="text" id="createdByInput" placeholder="Enter creator name">
<label>Mixed Items (Different MPN) - Optional</label>
<textarea id="itemsMixInput" placeholder='Enter JSON array of mixed items...'></textarea>
<div class="json-example">
Example:
[
{"mpn": "CN_M378A5143DB0-CPB_1621", "sn": "U03A00062074544403"}
]</div>
</div>
<div class="modal-buttons">
<button class="btn btn-secondary" onclick="closeCreateModal()">Cancel</button>
<button class="btn btn-primary" onclick="createProduct()">Create</button>
<button class="btn btn-success" onclick="createBatch()">Create Batch</button>
</div>
</div>
</div>
@ -418,38 +550,43 @@
let currentSort = 'id';
let currentOrder = 'DESC';
let searchTerm = '';
let URL = "https://logs1.danielvu.com/mobile-api/"
async function loadProducts() {
let URL = window.location.hostname === 'localhost'
? 'http://localhost:4444/api/'
: 'https://logs1.danielvu.com/mobile-api/';
let expandedBatches = new Set();
async function loadBatches() {
document.getElementById('loading').style.display = 'block';
document.getElementById('productTable').style.display = 'none';
document.getElementById('batchTable').style.display = 'none';
document.getElementById('empty').style.display = 'none';
try {
const response = await fetch(`${URL}product/get?page=${currentPage}&sortBy=${currentSort}&sortOrder=${currentOrder}&search=${encodeURIComponent(searchTerm)}`);
const response = await fetch(`${URL}batch/get-all?page=${currentPage}&sortBy=${currentSort}&sortOrder=${currentOrder}&search=${encodeURIComponent(searchTerm)}`);
const data = await response.json();
document.getElementById('loading').style.display = 'none';
if (data.products.length === 0) {
if (data.batches.length === 0) {
document.getElementById('empty').style.display = 'block';
} else {
document.getElementById('productTable').style.display = 'table';
renderProducts(data.products);
document.getElementById('batchTable').style.display = 'table';
renderBatches(data.batches);
renderPagination(data);
}
updateSortIndicators();
} catch (error) {
console.error('Error loading products:', error);
console.error('Error loading batches:', error);
document.getElementById('loading').style.display = 'none';
alert('Error loading batches. Please check if the server is running.');
}
}
function renderProducts(products) {
const tbody = document.getElementById('productBody');
tbody.innerHTML = products.map(product => {
// Convert UTC to client's local timezone
const date = new Date(product.createdAt+"Z");
function renderBatches(batches) {
const tbody = document.getElementById('batchBody');
tbody.innerHTML = batches.map(batch => {
const date = new Date(batch.createdAt + "Z");
const formattedDate = date.toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
@ -460,21 +597,59 @@
hour12: false
});
const isExpanded = expandedBatches.has(batch.id);
return `
<tr>
<td>${product.id}</td>
<td>${product.mpn}</td>
<td>${product.sn}</td>
<td>${formattedDate}</td>
<td>${product.createdBy}</td>
<tr class="batch-row ${isExpanded ? 'expanded' : ''}" onclick="toggleBatch(${batch.id})">
<td>
<button class="delete-btn" onclick="deleteProduct(${product.id})">Delete</button>
<span class="expand-icon ${isExpanded ? 'expanded' : ''}"></span>
${batch.id}
</td>
<td><strong>${batch.batch_name}</strong></td>
<td><span class="badge badge-success">${batch.items.length}</span></td>
<td><span class="badge badge-warning">${batch.items_mix.length}</span></td>
<td>${formattedDate}</td>
<td>
<button class="delete-btn" onclick="event.stopPropagation(); deleteBatch(${batch.id})">Delete</button>
</td>
</tr>
<tr class="items-row ${isExpanded ? 'show' : ''}" id="items-${batch.id}">
<td colspan="6" class="items-cell">
<div class="items-container">
<div class="items-section">
<h4>✅ Valid Items (${batch.items.length})</h4>
${batch.items.length > 0 ? batch.items.map(item => `
<div class="item-card">
<div><span class="label">MPN:</span> <span class="value">${item.mpn}</span></div>
<div><span class="label">SN:</span> <span class="value">${item.sn}</span></div>
</div>
`).join('') : '<p style="color: #999;">No items</p>'}
</div>
<div class="items-section mix">
<h4>⚠️ Mixed Items (${batch.items_mix.length})</h4>
${batch.items_mix.length > 0 ? batch.items_mix.map(item => `
<div class="item-card">
<div><span class="label">MPN:</span> <span class="value">${item.mpn}</span></div>
<div><span class="label">SN:</span> <span class="value">${item.sn}</span></div>
</div>
`).join('') : '<p style="color: #999;">No mixed items</p>'}
</div>
</div>
</td>
</tr>
`;
}).join('');
}
function toggleBatch(batchId) {
if (expandedBatches.has(batchId)) {
expandedBatches.delete(batchId);
} else {
expandedBatches.add(batchId);
}
loadBatches();
}
function renderPagination(data) {
const pagination = document.getElementById('pagination');
let html = '';
@ -489,14 +664,14 @@
}
html += `<button class="page-btn" ${currentPage === data.totalPages ? 'disabled' : ''} onclick="changePage(${currentPage + 1})">Next</button>`;
html += `<span class="page-info">Total: ${data.total} products</span>`;
html += `<span class="page-info">Total: ${data.total} batches</span>`;
pagination.innerHTML = html;
}
function changePage(page) {
currentPage = page;
loadProducts();
loadBatches();
}
function sortTable(column) {
@ -507,7 +682,7 @@
currentOrder = 'DESC';
}
currentPage = 1;
loadProducts();
loadBatches();
}
function updateSortIndicators() {
@ -515,7 +690,7 @@
th.classList.remove('sorted', 'asc');
});
const columns = ['id', 'mpn', 'sn', 'createdAt', 'createdBy'];
const columns = ['id', 'batch_name', 'createdAt'];
const index = columns.indexOf(currentSort);
if (index !== -1) {
const th = document.querySelectorAll('th')[index];
@ -532,73 +707,117 @@
searchTimeout = setTimeout(() => {
searchTerm = e.target.value;
currentPage = 1;
loadProducts();
expandedBatches.clear();
loadBatches();
}, 300);
});
async function deleteProduct(id) {
if (!confirm('Are you sure you want to delete this product?')) return;
async function deleteBatch(id) {
if (!confirm('Are you sure you want to delete this batch and all its items?')) return;
try {
const response = await fetch(`${URL}product/delete/${id}`, {
const response = await fetch(`${URL}batch/delete/${id}`, {
method: 'DELETE'
});
if (response.ok) {
loadProducts();
expandedBatches.delete(id);
loadBatches();
} else {
alert('Error deleting product');
alert('Error deleting batch');
}
} catch (error) {
console.error('Error deleting product:', error);
alert('Error deleting product');
console.error('Error deleting batch:', error);
alert('Error deleting batch');
}
}
function openCreateModal() {
document.getElementById('createModal').classList.add('active');
// Auto-generate batch name with timestamp
const now = new Date();
const timestamp = now.getFullYear() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0') +
String(now.getHours()).padStart(2, '0') +
String(now.getMinutes()).padStart(2, '0') +
String(now.getSeconds()).padStart(2, '0');
document.getElementById('batchNameInput').value = `BATCH_${timestamp}`;
}
function closeCreateModal() {
document.getElementById('createModal').classList.remove('active');
document.getElementById('mpnInput').value = '';
document.getElementById('snInput').value = '';
document.getElementById('createdByInput').value = '';
document.getElementById('batchNameInput').value = '';
document.getElementById('itemsInput').value = '';
document.getElementById('itemsMixInput').value = '';
}
async function createProduct() {
const mpn = document.getElementById('mpnInput').value.trim();
const sn = document.getElementById('snInput').value.trim();
const createdBy = document.getElementById('createdByInput').value.trim();
async function createBatch() {
const batch_name = document.getElementById('batchNameInput').value.trim();
const itemsText = document.getElementById('itemsInput').value.trim();
const itemsMixText = document.getElementById('itemsMixInput').value.trim();
if (!mpn || !sn || !createdBy) {
alert('Please fill in all fields');
if (!batch_name) {
alert('Please enter a batch name');
return;
}
if (!itemsText) {
alert('Please enter at least one item');
return;
}
let items, items_mix;
try {
items = JSON.parse(itemsText);
if (!Array.isArray(items) || items.length === 0) {
alert('Items must be a non-empty array');
return;
}
} catch (e) {
alert('Invalid JSON format for items: ' + e.message);
return;
}
try {
const response = await fetch(URL+'product/create', {
items_mix = itemsMixText ? JSON.parse(itemsMixText) : [];
if (!Array.isArray(items_mix)) {
alert('Mixed items must be an array');
return;
}
} catch (e) {
alert('Invalid JSON format for mixed items: ' + e.message);
return;
}
try {
const response = await fetch(URL + 'batch/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify([{ mpn, sn, createdBy }])
body: JSON.stringify({ batch_name, items, items_mix })
});
const result = await response.json();
if (response.ok) {
closeCreateModal();
loadProducts();
expandedBatches.clear();
loadBatches();
alert(`Batch created successfully!\nItems: ${result.inserted_items}\nMixed Items: ${result.inserted_mix_items}`);
} else {
alert('Error creating product');
alert('Error creating batch: ' + result.error);
}
} catch (error) {
console.error('Error creating product:', error);
alert('Error creating product');
console.error('Error creating batch:', error);
alert('Error creating batch: ' + error.message);
}
}
// Load products on page load
loadProducts();
// Load batches on page load
loadBatches();
</script>
</body>
</html>

279
server.js
View File

@ -7,58 +7,139 @@ const app = express();
app.use(express.json());
app.use(express.static('public'));
// CORS middleware
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
return res.sendStatus(200);
}
next();
});
// Initialize SQLite database
const db = new sqlite3.Database('./products.db', (err) => {
if (err) console.error(err.message);
console.log('Connected to products database.');
});
// Create products table
db.run(`CREATE TABLE IF NOT EXISTS products (
// Create tables
db.serialize(() => {
// Batches table
db.run(`CREATE TABLE IF NOT EXISTS batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_name TEXT NOT NULL UNIQUE,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Items table (for valid items)
db.run(`CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id INTEGER NOT NULL,
mpn TEXT NOT NULL,
sn TEXT NOT NULL,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
createdBy TEXT NOT NULL
)`);
FOREIGN KEY (batch_id) REFERENCES batches(id) ON DELETE CASCADE
)`);
// API Routes
// Items_mix table (for mixed MPN items)
db.run(`CREATE TABLE IF NOT EXISTS items_mix (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id INTEGER NOT NULL,
mpn TEXT NOT NULL,
sn TEXT NOT NULL,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (batch_id) REFERENCES batches(id) ON DELETE CASCADE
)`);
// Create products (batch insert)
app.post('/api/product/create', (req, res) => {
const products = req.body;
// Create indexes for better performance
db.run(`CREATE INDEX IF NOT EXISTS idx_items_batch_id ON items(batch_id)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_items_mix_batch_id ON items_mix(batch_id)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_items_mpn ON items(mpn)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_items_sn ON items(sn)`);
});
if (!Array.isArray(products) || products.length === 0) {
return res.status(400).json({ error: 'Expected an array of products' });
// ==================== BATCH API ROUTES ====================
// Save batch with items and items_mix
app.post('/api/batch/save', (req, res) => {
const { batch_name, items, items_mix } = req.body;
if (!batch_name) {
return res.status(400).json({ error: 'batch_name is required' });
}
const stmt = db.prepare('INSERT INTO products (mpn, sn, createdBy) VALUES (?, ?, ?)');
let inserted = 0;
if (!Array.isArray(items) || items.length === 0) {
return res.status(400).json({ error: 'items array is required and must not be empty' });
}
db.serialize(() => {
db.run('BEGIN TRANSACTION');
// Insert batch
db.run('INSERT INTO batches (batch_name) VALUES (?)', [batch_name], function(err) {
if (err) {
db.run('ROLLBACK');
return res.status(500).json({ error: 'Batch name already exists or database error: ' + err.message });
}
const batchId = this.lastID;
let insertedItems = 0;
let insertedMixItems = 0;
let errors = [];
products.forEach((product, index) => {
if (!product.mpn || !product.sn || !product.createdBy) {
errors.push(`Product at index ${index} is missing required fields`);
// Insert items
const itemStmt = db.prepare('INSERT INTO items (batch_id, mpn, sn) VALUES (?, ?, ?)');
items.forEach((item, index) => {
if (!item.mpn || !item.sn) {
errors.push(`Item at index ${index} is missing mpn or sn`);
return;
}
stmt.run(product.mpn, product.sn, product.createdBy, (err) => {
if (err) errors.push(`Error inserting product at index ${index}: ${err.message}`);
else inserted++;
itemStmt.run(batchId, item.mpn, item.sn, (err) => {
if (err) errors.push(`Error inserting item at index ${index}: ${err.message}`);
else insertedItems++;
});
});
itemStmt.finalize();
// Insert items_mix if provided
if (Array.isArray(items_mix) && items_mix.length > 0) {
const mixStmt = db.prepare('INSERT INTO items_mix (batch_id, mpn, sn) VALUES (?, ?, ?)');
items_mix.forEach((item, index) => {
if (!item.mpn || !item.sn) {
errors.push(`Mixed item at index ${index} is missing mpn or sn`);
return;
}
mixStmt.run(batchId, item.mpn, item.sn, (err) => {
if (err) errors.push(`Error inserting mixed item at index ${index}: ${err.message}`);
else insertedMixItems++;
});
});
mixStmt.finalize();
}
db.run('COMMIT', (err) => {
if (err) {
db.run('ROLLBACK');
return res.status(500).json({ error: 'Transaction failed: ' + err.message });
}
stmt.finalize(() => {
res.json({
success: true,
inserted,
batch_id: batchId,
batch_name,
inserted_items: insertedItems,
inserted_mix_items: insertedMixItems,
errors: errors.length > 0 ? errors : undefined
});
});
});
});
});
// Get products with pagination, search, and sort
app.get('/api/product/get', (req, res) => {
// Get all batches with their items and items_mix
app.get('/api/batch/get-all', (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 50;
const search = req.query.search || '';
@ -66,64 +147,190 @@ app.get('/api/product/get', (req, res) => {
const sortOrder = req.query.sortOrder || 'DESC';
const offset = (page - 1) * limit;
// Validate sortBy to prevent SQL injection
const validColumns = ['id', 'mpn', 'sn', 'createdAt', 'createdBy'];
// Validate sortBy
const validColumns = ['id', 'batch_name', 'createdAt'];
const column = validColumns.includes(sortBy) ? sortBy : 'id';
const order = sortOrder.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
let query = 'SELECT * FROM products';
let countQuery = 'SELECT COUNT(*) as total FROM products';
let query = 'SELECT * FROM batches';
let countQuery = 'SELECT COUNT(*) as total FROM batches';
let params = [];
if (search) {
const searchCondition = ` WHERE mpn LIKE ? OR sn LIKE ? OR createdBy LIKE ? OR id LIKE ?`;
const searchCondition = ` WHERE batch_name LIKE ? OR id LIKE ?`;
query += searchCondition;
countQuery += searchCondition;
const searchParam = `%${search}%`;
params = [searchParam, searchParam, searchParam, searchParam];
params = [searchParam, searchParam];
}
query += ` ORDER BY ${column} ${order} LIMIT ? OFFSET ?`;
params.push(limit, offset);
db.get(countQuery, search ? params.slice(0, 4) : [], (err, countRow) => {
db.get(countQuery, search ? params.slice(0, 2) : [], (err, countRow) => {
if (err) {
return res.status(500).json({ error: err.message });
}
db.all(query, params, (err, rows) => {
db.all(query, params, (err, batches) => {
if (err) {
return res.status(500).json({ error: err.message });
}
if (batches.length === 0) {
return res.json({
batches: [],
total: 0,
page,
limit,
totalPages: 0
});
}
// Get items for each batch
let processed = 0;
const batchesWithItems = [];
batches.forEach(batch => {
// Get items
db.all('SELECT mpn, sn, createdAt FROM items WHERE batch_id = ?', [batch.id], (err, items) => {
if (err) {
console.error('Error fetching items:', err);
items = [];
}
// Get items_mix
db.all('SELECT mpn, sn, createdAt FROM items_mix WHERE batch_id = ?', [batch.id], (err, items_mix) => {
if (err) {
console.error('Error fetching items_mix:', err);
items_mix = [];
}
batchesWithItems.push({
id: batch.id,
batch_name: batch.batch_name,
createdAt: batch.createdAt,
items: items || [],
items_mix: items_mix || []
});
processed++;
if (processed === batches.length) {
res.json({
products: rows,
batches: batchesWithItems,
total: countRow.total,
page,
limit,
totalPages: Math.ceil(countRow.total / limit)
});
}
});
});
});
});
});
});
// Delete product
app.delete('/api/product/delete/:id', (req, res) => {
// Get single batch by ID
app.get('/api/batch/get/:id', (req, res) => {
const id = req.params.id;
db.run('DELETE FROM products WHERE id = ?', id, function(err) {
db.get('SELECT * FROM batches WHERE id = ?', [id], (err, batch) => {
if (err) {
return res.status(500).json({ error: err.message });
}
if (!batch) {
return res.status(404).json({ error: 'Batch not found' });
}
// Get items
db.all('SELECT mpn, sn, createdAt FROM items WHERE batch_id = ?', [id], (err, items) => {
if (err) {
return res.status(500).json({ error: err.message });
}
// Get items_mix
db.all('SELECT mpn, sn, createdAt FROM items_mix WHERE batch_id = ?', [id], (err, items_mix) => {
if (err) {
return res.status(500).json({ error: err.message });
}
res.json({
id: batch.id,
batch_name: batch.batch_name,
createdAt: batch.createdAt,
items: items || [],
items_mix: items_mix || []
});
});
});
});
});
// Delete batch (cascade delete items and items_mix)
app.delete('/api/batch/delete/:id', (req, res) => {
const id = req.params.id;
db.run('DELETE FROM batches WHERE id = ?', id, function(err) {
if (err) {
return res.status(500).json({ error: err.message });
}
if (this.changes === 0) {
return res.status(404).json({ error: 'Product not found' });
return res.status(404).json({ error: 'Batch not found' });
}
res.json({ success: true, deleted: this.changes });
});
});
// Search items across all batches
app.get('/api/items/search', (req, res) => {
const search = req.query.q || '';
if (!search) {
return res.status(400).json({ error: 'Search query is required' });
}
const searchParam = `%${search}%`;
const query = `
SELECT
b.id as batch_id,
b.batch_name,
i.mpn,
i.sn,
i.createdAt,
'items' as type
FROM items i
JOIN batches b ON i.batch_id = b.id
WHERE i.mpn LIKE ? OR i.sn LIKE ?
UNION ALL
SELECT
b.id as batch_id,
b.batch_name,
im.mpn,
im.sn,
im.createdAt,
'items_mix' as type
FROM items_mix im
JOIN batches b ON im.batch_id = b.id
WHERE im.mpn LIKE ? OR im.sn LIKE ?
ORDER BY createdAt DESC
LIMIT 100
`;
db.all(query, [searchParam, searchParam, searchParam, searchParam], (err, results) => {
if (err) {
return res.status(500).json({ error: err.message });
}
res.json({ results });
});
});
// Serve HTML UI
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));

95
test_api.js Normal file
View File

@ -0,0 +1,95 @@
// Test script for Batch Management API
// Run with: node test-api.js
const BASE_URL = 'http://localhost:4444/api';
async function testAPI() {
console.log('🚀 Testing Batch Management API\n');
// Test 1: Create Batch
console.log('1⃣ Creating a new batch...');
try {
const now = new Date();
const timestamp = now.getFullYear() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0') +
String(now.getHours()).padStart(2, '0') +
String(now.getMinutes()).padStart(2, '0') +
String(now.getSeconds()).padStart(2, '0');
const createResponse = await fetch(`${BASE_URL}/batch/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
batch_name: `TEST_BATCH_${timestamp}`,
items: [
{ mpn: 'CN_M378A5143DB0-CPB_1620', sn: 'U03A00062074544398' },
{ mpn: 'CN_M378A5143DB0-CPB_1620', sn: 'U03A00062074544399' },
{ mpn: 'CN_M378A5143DB0-CPB_1620', sn: 'U03A00062074544400' }
],
items_mix: [
{ mpn: 'CN_M378A5143DB0-CPB_1621', sn: 'U03A00062074544403' },
{ mpn: 'CN_M378A5143DB0-CPB_1622', sn: 'U03A00062074544404' }
]
})
});
const createData = await createResponse.json();
console.log('✅ Batch created:', createData);
console.log(` - Batch ID: ${createData.batch_id}`);
console.log(` - Items inserted: ${createData.inserted_items}`);
console.log(` - Mixed items inserted: ${createData.inserted_mix_items}\n`);
const batchId = createData.batch_id;
// Test 2: Get All Batches
console.log('2⃣ Getting all batches...');
const getAllResponse = await fetch(`${BASE_URL}/batch/get-all?page=1&limit=10`);
const getAllData = await getAllResponse.json();
console.log(`✅ Found ${getAllData.total} batches`);
console.log(` - Page ${getAllData.page} of ${getAllData.totalPages}\n`);
// Test 3: Get Single Batch
console.log(`3⃣ Getting batch #${batchId}...`);
const getSingleResponse = await fetch(`${BASE_URL}/batch/get/${batchId}`);
const getSingleData = await getSingleResponse.json();
console.log('✅ Batch details:');
console.log(` - Batch Name: ${getSingleData.batch_name}`);
console.log(` - Items: ${getSingleData.items.length}`);
console.log(` - Mixed Items: ${getSingleData.items_mix.length}`);
console.log(` - Created At: ${getSingleData.createdAt}\n`);
// Test 4: Search Items
console.log('4⃣ Searching for items with SN containing "544398"...');
const searchResponse = await fetch(`${BASE_URL}/items/search?q=544398`);
const searchData = await searchResponse.json();
console.log(`✅ Found ${searchData.results.length} items`);
if (searchData.results.length > 0) {
console.log(' First result:', searchData.results[0]);
}
console.log();
// Test 5: Search Batches
console.log('5⃣ Searching for batches with "TEST" in name...');
const searchBatchResponse = await fetch(`${BASE_URL}/batch/get-all?search=TEST`);
const searchBatchData = await searchBatchResponse.json();
console.log(`✅ Found ${searchBatchData.total} batches matching "TEST"\n`);
// Test 6: Delete Batch
console.log(`6⃣ Deleting batch #${batchId}...`);
const deleteResponse = await fetch(`${BASE_URL}/batch/delete/${batchId}`, {
method: 'DELETE'
});
const deleteData = await deleteResponse.json();
console.log('✅ Batch deleted:', deleteData);
console.log(` - Deleted count: ${deleteData.deleted}\n`);
console.log('🎉 All tests completed successfully!');
} catch (error) {
console.error('❌ Test failed:', error.message);
}
}
// Run tests
testAPI();