project-startup 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/Logics/Booking/controllers/bookingController.js +117 -0
- package/template/Logics/Booking/controllers/busController.js +95 -0
- package/template/Logics/Booking/schema.sql +47 -0
- package/template/Logics/Stocks/controllers/sparePartController.js +104 -0
- package/template/Logics/Stocks/controllers/stockInController.js +159 -0
- package/template/Logics/Stocks/controllers/stockOutController.js +183 -0
- package/template/Logics/Stocks/schema.sql +53 -0
package/package.json
CHANGED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const db = require("../config/db");
|
|
2
|
+
|
|
3
|
+
// GET /api/bookings
|
|
4
|
+
exports.getBookings = async (req, res) => {
|
|
5
|
+
if (req.user.role === "manager") {
|
|
6
|
+
const [bookings] = await db.query(
|
|
7
|
+
`SELECT bk.id, bk.seats, bk.total_rwf, bk.booked_at,
|
|
8
|
+
u.name AS customer_name, u.email AS customer_email,
|
|
9
|
+
b.plate_number, b.destination, b.departure_time, b.price_rwf
|
|
10
|
+
FROM bookings bk
|
|
11
|
+
JOIN users u ON u.id = bk.customer_id
|
|
12
|
+
JOIN buses b ON b.id = bk.bus_id
|
|
13
|
+
ORDER BY bk.booked_at DESC`
|
|
14
|
+
);
|
|
15
|
+
return res.json(bookings);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const [bookings] = await db.query(
|
|
19
|
+
`SELECT bk.id, bk.seats, bk.total_rwf, bk.booked_at,
|
|
20
|
+
b.plate_number, b.destination, b.departure_time, b.price_rwf
|
|
21
|
+
FROM bookings bk
|
|
22
|
+
JOIN buses b ON b.id = bk.bus_id
|
|
23
|
+
WHERE bk.customer_id = ?
|
|
24
|
+
ORDER BY bk.booked_at DESC`,
|
|
25
|
+
[req.user.id]
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
res.json(bookings);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// POST /api/bookings (customer only) — SIMPLIFIED
|
|
32
|
+
exports.createBooking = async (req, res) => {
|
|
33
|
+
const { busId, seats } = req.body;
|
|
34
|
+
|
|
35
|
+
if (!busId || !seats || seats < 1) {
|
|
36
|
+
return res.status(400).json({ error: "busId and seats required" });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 1. Get the bus
|
|
40
|
+
const [buses] = await db.query(
|
|
41
|
+
"SELECT * FROM buses WHERE id = ?",
|
|
42
|
+
[busId]
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (buses.length === 0) {
|
|
46
|
+
return res.status(404).json({ error: "Bus not found" });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const bus = buses[0];
|
|
50
|
+
|
|
51
|
+
// 2. Check departure time
|
|
52
|
+
if (new Date(bus.departure_time) <= new Date()) {
|
|
53
|
+
return res.status(400).json({ error: "Bus already departed" });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 3. Check seats (simple check, no lock)
|
|
57
|
+
if (seats > bus.available_seats) {
|
|
58
|
+
return res.status(400).json({
|
|
59
|
+
error: `Only ${bus.available_seats} seat(s) available`
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 4. Decrement seats AND insert booking (two separate queries)
|
|
64
|
+
await db.query(
|
|
65
|
+
"UPDATE buses SET available_seats = available_seats - ? WHERE id = ?",
|
|
66
|
+
[seats, busId]
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const [result] = await db.query(
|
|
70
|
+
`INSERT INTO bookings (bus_id, customer_id, seats, total_rwf)
|
|
71
|
+
VALUES (?, ?, ?, ?)`,
|
|
72
|
+
[busId, req.user.id, seats, seats * bus.price_rwf]
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// 5. Return the new booking
|
|
76
|
+
res.status(201).json({
|
|
77
|
+
id: result.insertId,
|
|
78
|
+
bus_id: busId,
|
|
79
|
+
customer_id: req.user.id,
|
|
80
|
+
seats,
|
|
81
|
+
total_rwf: seats * bus.price_rwf,
|
|
82
|
+
plate_number: bus.plate_number,
|
|
83
|
+
destination: bus.destination,
|
|
84
|
+
departure_time: bus.departure_time
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// DELETE /api/bookings/:id — SIMPLIFIED
|
|
89
|
+
exports.cancelBooking = async (req, res) => {
|
|
90
|
+
// 1. Find booking and check it belongs to user
|
|
91
|
+
const [bookings] = await db.query(
|
|
92
|
+
`SELECT bk.*, b.departure_time
|
|
93
|
+
FROM bookings bk
|
|
94
|
+
JOIN buses b ON b.id = bk.bus_id
|
|
95
|
+
WHERE bk.id = ? AND bk.customer_id = ?`,
|
|
96
|
+
[req.params.id, req.user.id]
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (bookings.length === 0) {
|
|
100
|
+
return res.status(404).json({ error: "Booking not found" });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const booking = bookings[0];
|
|
104
|
+
|
|
105
|
+
// 2. Restore seats only if bus hasn't departed
|
|
106
|
+
if (new Date(booking.departure_time) > new Date()) {
|
|
107
|
+
await db.query(
|
|
108
|
+
"UPDATE buses SET available_seats = available_seats + ? WHERE id = ?",
|
|
109
|
+
[booking.seats, booking.bus_id]
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 3. Delete booking
|
|
114
|
+
await db.query("DELETE FROM bookings WHERE id = ?", [req.params.id]);
|
|
115
|
+
|
|
116
|
+
res.json({ message: "Booking cancelled" });
|
|
117
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const db = require("../config/db");
|
|
2
|
+
|
|
3
|
+
// GET /api/buses
|
|
4
|
+
exports.getAllBuses = async (req, res) => {
|
|
5
|
+
const [buses] = await db.query(
|
|
6
|
+
"SELECT * FROM buses ORDER BY departure_time ASC"
|
|
7
|
+
);
|
|
8
|
+
res.json(buses);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// GET /api/buses/:id
|
|
12
|
+
exports.getBusById = async (req, res) => {
|
|
13
|
+
const [buses] = await db.query(
|
|
14
|
+
"SELECT * FROM buses WHERE id = ?",
|
|
15
|
+
[req.params.id]
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
if (buses.length === 0) {
|
|
19
|
+
return res.status(404).json({ error: "Bus not found" });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
res.json(buses[0]);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// POST /api/buses (manager only) — SIMPLIFIED
|
|
26
|
+
exports.createBus = async (req, res) => {
|
|
27
|
+
const { plateNumber, destination, maxSeats, departureTime, priceRwf } = req.body;
|
|
28
|
+
|
|
29
|
+
if (!plateNumber || !destination || !maxSeats || !departureTime || !priceRwf) {
|
|
30
|
+
return res.status(400).json({ error: "All fields required" });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (new Date(deploymentTime) <= new Date()) {
|
|
34
|
+
return res.status(400).json({ error: "Departure time must be in future" });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const [result] = await db.query(
|
|
38
|
+
`INSERT INTO buses
|
|
39
|
+
(plate_number, destination, max_seats, available_seats, departure_time, price_rwf)
|
|
40
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
41
|
+
[plateNumber, destination, maxSeats, maxSeats, departureTime, priceRwf]
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const [buses] = await db.query(
|
|
45
|
+
"SELECT * FROM buses WHERE id = ?",
|
|
46
|
+
[result.insertId]
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
res.status(201).json(buses[0]);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// PUT /api/buses/:id (manager only) — SIMPLIFIED
|
|
53
|
+
exports.updateBus = async (req, res) => {
|
|
54
|
+
const { plateNumber, destination, departureTime, priceRwf } = req.body;
|
|
55
|
+
|
|
56
|
+
await db.query(
|
|
57
|
+
`UPDATE buses SET
|
|
58
|
+
plate_number = COALESCE(?, plate_number),
|
|
59
|
+
destination = COALESCE(?, destination),
|
|
60
|
+
departure_time = COALESCE(?, departure_time),
|
|
61
|
+
price_rwf = COALESCE(?, price_rwf)
|
|
62
|
+
WHERE id = ?`,
|
|
63
|
+
[plateNumber, destination, departureTime, priceRwf, req.params.id]
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const [buses] = await db.query(
|
|
67
|
+
"SELECT * FROM buses WHERE id = ?",
|
|
68
|
+
[req.params.id]
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
res.json(buses[0]);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// DELETE /api/buses/:id (manager only)
|
|
75
|
+
exports.deleteBus = async (req, res) => {
|
|
76
|
+
const [bookings] = await db.query(
|
|
77
|
+
"SELECT id FROM bookings WHERE bus_id = ?",
|
|
78
|
+
[req.params.id]
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (bookings.length > 0) {
|
|
82
|
+
return res.status(400).json({ error: "Cannot delete bus with bookings" });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const [result] = await db.query(
|
|
86
|
+
"DELETE FROM buses WHERE id = ?",
|
|
87
|
+
[req.params.id]
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (result.affectedRows === 0) {
|
|
91
|
+
return res.status(404).json({ error: "Bus not found" });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
res.json({ message: "Bus deleted" });
|
|
95
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
-- Run this file once to set up your database:
|
|
2
|
+
-- mysql -u root -p < schema.sql
|
|
3
|
+
|
|
4
|
+
CREATE DATABASE IF NOT EXISTS fleet_db;
|
|
5
|
+
USE fleet_db;
|
|
6
|
+
|
|
7
|
+
-- ─── Users ────────────────────────────────────────────────────────────────────
|
|
8
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
9
|
+
id INT PRIMARY KEY AUTO_INCREMENT,
|
|
10
|
+
name VARCHAR(100) NOT NULL,
|
|
11
|
+
email VARCHAR(150) UNIQUE NOT NULL,
|
|
12
|
+
password VARCHAR(255) NOT NULL,
|
|
13
|
+
role ENUM('manager', 'customer') NOT NULL,
|
|
14
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
-- ─── Buses ────────────────────────────────────────────────────────────────────
|
|
18
|
+
CREATE TABLE IF NOT EXISTS buses (
|
|
19
|
+
id INT PRIMARY KEY AUTO_INCREMENT,
|
|
20
|
+
plate_number VARCHAR(20) UNIQUE NOT NULL,
|
|
21
|
+
destination VARCHAR(100) NOT NULL,
|
|
22
|
+
max_seats INT NOT NULL,
|
|
23
|
+
available_seats INT NOT NULL,
|
|
24
|
+
departure_time DATETIME NOT NULL,
|
|
25
|
+
price_rwf INT NOT NULL,
|
|
26
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
-- ─── Bookings ─────────────────────────────────────────────────────────────────
|
|
30
|
+
CREATE TABLE IF NOT EXISTS bookings (
|
|
31
|
+
id INT PRIMARY KEY AUTO_INCREMENT,
|
|
32
|
+
bus_id INT NOT NULL,
|
|
33
|
+
customer_id INT NOT NULL,
|
|
34
|
+
seats INT NOT NULL,
|
|
35
|
+
total_rwf INT NOT NULL,
|
|
36
|
+
booked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
37
|
+
FOREIGN KEY (bus_id) REFERENCES buses(id) ON DELETE CASCADE,
|
|
38
|
+
FOREIGN KEY (customer_id) REFERENCES users(id) ON DELETE CASCADE
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
-- ─── Sessions ─────────────────────────────────────────────────────────────────
|
|
42
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
43
|
+
token VARCHAR(255) PRIMARY KEY,
|
|
44
|
+
user_id INT NOT NULL,
|
|
45
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
46
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
47
|
+
);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const db = require("../db");
|
|
2
|
+
|
|
3
|
+
// GET /api/parts
|
|
4
|
+
exports.getAllParts = async (req, res) => {
|
|
5
|
+
const [parts] = await db.query(
|
|
6
|
+
"SELECT * FROM spare_parts ORDER BY name ASC"
|
|
7
|
+
);
|
|
8
|
+
res.json(parts);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// GET /api/parts/:id
|
|
12
|
+
exports.getPartById = async (req, res) => {
|
|
13
|
+
const [rows] = await db.query(
|
|
14
|
+
"SELECT * FROM spare_parts WHERE id = ?",
|
|
15
|
+
[req.params.id]
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
if (rows.length === 0) {
|
|
19
|
+
return res.status(404).json({ error: "Spare part not found." });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
res.json(rows[0]);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// POST /api/parts
|
|
26
|
+
exports.createPart = async (req, res) => {
|
|
27
|
+
const { name, quantity, unitPrice } = req.body;
|
|
28
|
+
|
|
29
|
+
if (!name || quantity == null || !unitPrice) {
|
|
30
|
+
return res.status(400).json({ error: "name, quantity and unitPrice are required." });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (quantity < 0) {
|
|
34
|
+
return res.status(400).json({ error: "Quantity cannot be negative." });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const [result] = await db.query(
|
|
38
|
+
"INSERT INTO spare_parts (name, quantity, unit_price) VALUES (?, ?, ?)",
|
|
39
|
+
[name.trim(), quantity, unitPrice]
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const [rows] = await db.query(
|
|
43
|
+
"SELECT * FROM spare_parts WHERE id = ?",
|
|
44
|
+
[result.insertId]
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
res.status(201).json(rows[0]);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// PUT /api/parts/:id
|
|
51
|
+
exports.updatePart = async (req, res) => {
|
|
52
|
+
const [rows] = await db.query(
|
|
53
|
+
"SELECT * FROM spare_parts WHERE id = ?",
|
|
54
|
+
[req.params.id]
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (rows.length === 0) {
|
|
58
|
+
return res.status(404).json({ error: "Spare part not found." });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const { name, unitPrice } = req.body;
|
|
62
|
+
// Note: quantity is managed by stock-in / stock-out, not edited directly
|
|
63
|
+
|
|
64
|
+
await db.query(
|
|
65
|
+
`UPDATE spare_parts SET
|
|
66
|
+
name = COALESCE(?, name),
|
|
67
|
+
unit_price = COALESCE(?, unit_price)
|
|
68
|
+
WHERE id = ?`,
|
|
69
|
+
[name || null, unitPrice || null, req.params.id]
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const [updated] = await db.query(
|
|
73
|
+
"SELECT * FROM spare_parts WHERE id = ?",
|
|
74
|
+
[req.params.id]
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
res.json(updated[0]);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// DELETE /api/parts/:id
|
|
81
|
+
exports.deletePart = async (req, res) => {
|
|
82
|
+
// Block deletion if there are any stock-in records for this part
|
|
83
|
+
const [stockIns] = await db.query(
|
|
84
|
+
"SELECT id FROM stock_in WHERE spare_part_id = ?",
|
|
85
|
+
[req.params.id]
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (stockIns.length > 0) {
|
|
89
|
+
return res.status(400).json({
|
|
90
|
+
error: "Cannot delete a part that has stock-in records.",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const [result] = await db.query(
|
|
95
|
+
"DELETE FROM spare_parts WHERE id = ?",
|
|
96
|
+
[req.params.id]
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (result.affectedRows === 0) {
|
|
100
|
+
return res.status(404).json({ error: "Spare part not found." });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
res.json({ message: "Spare part deleted." });
|
|
104
|
+
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
const db = require("../db");
|
|
2
|
+
|
|
3
|
+
// GET /api/stock-in
|
|
4
|
+
// Returns all import records joined with part name
|
|
5
|
+
exports.getAllStockIn = async (req, res) => {
|
|
6
|
+
const [rows] = await db.query(
|
|
7
|
+
`SELECT
|
|
8
|
+
si.id,
|
|
9
|
+
si.quantity,
|
|
10
|
+
si.remaining_qty,
|
|
11
|
+
si.unit_price,
|
|
12
|
+
si.total_price,
|
|
13
|
+
si.imported_at,
|
|
14
|
+
sp.id AS spare_part_id,
|
|
15
|
+
sp.name AS spare_part_name
|
|
16
|
+
FROM stock_in si
|
|
17
|
+
JOIN spare_parts sp ON sp.id = si.spare_part_id
|
|
18
|
+
ORDER BY si.imported_at DESC`
|
|
19
|
+
);
|
|
20
|
+
res.json(rows);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// GET /api/stock-in/:id
|
|
24
|
+
exports.getStockInById = async (req, res) => {
|
|
25
|
+
const [rows] = await db.query(
|
|
26
|
+
`SELECT
|
|
27
|
+
si.*,
|
|
28
|
+
sp.name AS spare_part_name
|
|
29
|
+
FROM stock_in si
|
|
30
|
+
JOIN spare_parts sp ON sp.id = si.spare_part_id
|
|
31
|
+
WHERE si.id = ?`,
|
|
32
|
+
[req.params.id]
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
if (rows.length === 0) {
|
|
36
|
+
return res.status(404).json({ error: "Stock-in record not found." });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
res.json(rows[0]);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// POST /api/stock-in
|
|
43
|
+
// Creates a new import batch and increments spare_parts.quantity
|
|
44
|
+
exports.createStockIn = async (req, res) => {
|
|
45
|
+
const { sparePartId, quantity, unitPrice } = req.body;
|
|
46
|
+
|
|
47
|
+
if (!sparePartId || !quantity || !unitPrice) {
|
|
48
|
+
return res.status(400).json({
|
|
49
|
+
error: "sparePartId, quantity and unitPrice are required.",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (quantity < 1) {
|
|
54
|
+
return res.status(400).json({ error: "Quantity must be at least 1." });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Verify the spare part exists
|
|
58
|
+
const [partRows] = await db.query(
|
|
59
|
+
"SELECT * FROM spare_parts WHERE id = ?",
|
|
60
|
+
[sparePartId]
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
if (partRows.length === 0) {
|
|
64
|
+
return res.status(404).json({ error: "Spare part not found." });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const totalPrice = quantity * unitPrice;
|
|
68
|
+
|
|
69
|
+
const conn = await db.getConnection();
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await conn.beginTransaction();
|
|
73
|
+
|
|
74
|
+
// Insert the stock-in batch
|
|
75
|
+
// remaining_qty starts equal to quantity — decremented by stock-out later
|
|
76
|
+
const [result] = await conn.query(
|
|
77
|
+
`INSERT INTO stock_in
|
|
78
|
+
(spare_part_id, quantity, remaining_qty, unit_price, total_price)
|
|
79
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
80
|
+
[sparePartId, quantity, quantity, unitPrice, totalPrice]
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Increment the part's total stock quantity
|
|
84
|
+
await conn.query(
|
|
85
|
+
"UPDATE spare_parts SET quantity = quantity + ? WHERE id = ?",
|
|
86
|
+
[quantity, sparePartId]
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
await conn.commit();
|
|
90
|
+
|
|
91
|
+
// Return the full record with part name
|
|
92
|
+
const [rows] = await conn.query(
|
|
93
|
+
`SELECT si.*, sp.name AS spare_part_name
|
|
94
|
+
FROM stock_in si
|
|
95
|
+
JOIN spare_parts sp ON sp.id = si.spare_part_id
|
|
96
|
+
WHERE si.id = ?`,
|
|
97
|
+
[result.insertId]
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
res.status(201).json(rows[0]);
|
|
101
|
+
|
|
102
|
+
} catch (err) {
|
|
103
|
+
await conn.rollback();
|
|
104
|
+
throw err;
|
|
105
|
+
} finally {
|
|
106
|
+
conn.release();
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// DELETE /api/stock-in/:id
|
|
111
|
+
// Only allowed if no stock-out records reference this batch
|
|
112
|
+
exports.deleteStockIn = async (req, res) => {
|
|
113
|
+
const [rows] = await db.query(
|
|
114
|
+
"SELECT * FROM stock_in WHERE id = ?",
|
|
115
|
+
[req.params.id]
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
if (rows.length === 0) {
|
|
119
|
+
return res.status(404).json({ error: "Stock-in record not found." });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const stockIn = rows[0];
|
|
123
|
+
|
|
124
|
+
// Block deletion if sales came out of this batch
|
|
125
|
+
const [sales] = await db.query(
|
|
126
|
+
"SELECT id FROM stock_out WHERE stock_in_id = ?",
|
|
127
|
+
[req.params.id]
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (sales.length > 0) {
|
|
131
|
+
return res.status(400).json({
|
|
132
|
+
error: "Cannot delete a batch that has stock-out records.",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const conn = await db.getConnection();
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await conn.beginTransaction();
|
|
140
|
+
|
|
141
|
+
// Give the quantity back to the spare part
|
|
142
|
+
await conn.query(
|
|
143
|
+
"UPDATE spare_parts SET quantity = quantity - ? WHERE id = ?",
|
|
144
|
+
[stockIn.quantity, stockIn.spare_part_id]
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
await conn.query("DELETE FROM stock_in WHERE id = ?", [req.params.id]);
|
|
148
|
+
|
|
149
|
+
await conn.commit();
|
|
150
|
+
|
|
151
|
+
res.json({ message: "Stock-in record deleted and quantity reversed." });
|
|
152
|
+
|
|
153
|
+
} catch (err) {
|
|
154
|
+
await conn.rollback();
|
|
155
|
+
throw err;
|
|
156
|
+
} finally {
|
|
157
|
+
conn.release();
|
|
158
|
+
}
|
|
159
|
+
};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
const db = require("../db");
|
|
2
|
+
|
|
3
|
+
// GET /api/stock-out
|
|
4
|
+
exports.getAllStockOut = async (req, res) => {
|
|
5
|
+
const [rows] = await db.query(
|
|
6
|
+
`SELECT
|
|
7
|
+
so.id,
|
|
8
|
+
so.quantity,
|
|
9
|
+
so.unit_price,
|
|
10
|
+
so.total_price,
|
|
11
|
+
so.sold_at,
|
|
12
|
+
sp.id AS spare_part_id,
|
|
13
|
+
sp.name AS spare_part_name,
|
|
14
|
+
si.id AS stock_in_id,
|
|
15
|
+
si.unit_price AS import_unit_price,
|
|
16
|
+
si.imported_at
|
|
17
|
+
FROM stock_out so
|
|
18
|
+
JOIN stock_in si ON si.id = so.stock_in_id
|
|
19
|
+
JOIN spare_parts sp ON sp.id = so.spare_part_id
|
|
20
|
+
ORDER BY so.sold_at DESC`
|
|
21
|
+
);
|
|
22
|
+
res.json(rows);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// GET /api/stock-out/:id
|
|
26
|
+
exports.getStockOutById = async (req, res) => {
|
|
27
|
+
const [rows] = await db.query(
|
|
28
|
+
`SELECT
|
|
29
|
+
so.*,
|
|
30
|
+
sp.name AS spare_part_name,
|
|
31
|
+
si.remaining_qty,
|
|
32
|
+
si.unit_price AS import_unit_price
|
|
33
|
+
FROM stock_out so
|
|
34
|
+
JOIN stock_in si ON si.id = so.stock_in_id
|
|
35
|
+
JOIN spare_parts sp ON sp.id = so.spare_part_id
|
|
36
|
+
WHERE so.id = ?`,
|
|
37
|
+
[req.params.id]
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
if (rows.length === 0) {
|
|
41
|
+
return res.status(404).json({ error: "Stock-out record not found." });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
res.json(rows[0]);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// POST /api/stock-out
|
|
48
|
+
// Validates against stock_in.remaining_qty, then decrements both
|
|
49
|
+
// stock_in.remaining_qty and spare_parts.quantity
|
|
50
|
+
exports.createStockOut = async (req, res) => {
|
|
51
|
+
const { stockInId, quantity, unitPrice } = req.body;
|
|
52
|
+
|
|
53
|
+
if (!stockInId || !quantity || !unitPrice) {
|
|
54
|
+
return res.status(400).json({
|
|
55
|
+
error: "stockInId, quantity and unitPrice are required.",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (quantity < 1) {
|
|
60
|
+
return res.status(400).json({ error: "Quantity must be at least 1." });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const conn = await db.getConnection();
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
await conn.beginTransaction();
|
|
67
|
+
|
|
68
|
+
// Lock the stock_in row so two concurrent sales can't oversell the batch
|
|
69
|
+
const [siRows] = await conn.query(
|
|
70
|
+
`SELECT si.*, sp.id AS part_id
|
|
71
|
+
FROM stock_in si
|
|
72
|
+
JOIN spare_parts sp ON sp.id = si.spare_part_id
|
|
73
|
+
WHERE si.id = ?
|
|
74
|
+
FOR UPDATE`,
|
|
75
|
+
[stockInId]
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (siRows.length === 0) {
|
|
79
|
+
await conn.rollback();
|
|
80
|
+
return res.status(404).json({ error: "Stock-in batch not found." });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const batch = siRows[0];
|
|
84
|
+
|
|
85
|
+
// Quantity sold must not exceed what remains in this specific batch
|
|
86
|
+
if (quantity > batch.remaining_qty) {
|
|
87
|
+
await conn.rollback();
|
|
88
|
+
return res.status(400).json({
|
|
89
|
+
error: `Only ${batch.remaining_qty} unit(s) remaining in this batch.`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const totalPrice = quantity * unitPrice;
|
|
94
|
+
|
|
95
|
+
// Insert the sale record
|
|
96
|
+
const [result] = await conn.query(
|
|
97
|
+
`INSERT INTO stock_out
|
|
98
|
+
(stock_in_id, spare_part_id, quantity, unit_price, total_price)
|
|
99
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
100
|
+
[stockInId, batch.spare_part_id, quantity, unitPrice, totalPrice]
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Decrement remaining units in this import batch
|
|
104
|
+
await conn.query(
|
|
105
|
+
"UPDATE stock_in SET remaining_qty = remaining_qty - ? WHERE id = ?",
|
|
106
|
+
[quantity, stockInId]
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Decrement the part's total stock
|
|
110
|
+
await conn.query(
|
|
111
|
+
"UPDATE spare_parts SET quantity = quantity - ? WHERE id = ?",
|
|
112
|
+
[quantity, batch.spare_part_id]
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
await conn.commit();
|
|
116
|
+
|
|
117
|
+
// Return full record with names attached
|
|
118
|
+
const [rows] = await conn.query(
|
|
119
|
+
`SELECT
|
|
120
|
+
so.*,
|
|
121
|
+
sp.name AS spare_part_name,
|
|
122
|
+
si.unit_price AS import_unit_price,
|
|
123
|
+
si.remaining_qty
|
|
124
|
+
FROM stock_out so
|
|
125
|
+
JOIN stock_in si ON si.id = so.stock_in_id
|
|
126
|
+
JOIN spare_parts sp ON sp.id = so.spare_part_id
|
|
127
|
+
WHERE so.id = ?`,
|
|
128
|
+
[result.insertId]
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
res.status(201).json(rows[0]);
|
|
132
|
+
|
|
133
|
+
} catch (err) {
|
|
134
|
+
await conn.rollback();
|
|
135
|
+
throw err;
|
|
136
|
+
} finally {
|
|
137
|
+
conn.release();
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// DELETE /api/stock-out/:id
|
|
142
|
+
// Reverses the sale: restores remaining_qty on the batch and quantity on the part
|
|
143
|
+
exports.deleteStockOut = async (req, res) => {
|
|
144
|
+
const [rows] = await db.query(
|
|
145
|
+
"SELECT * FROM stock_out WHERE id = ?",
|
|
146
|
+
[req.params.id]
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
if (rows.length === 0) {
|
|
150
|
+
return res.status(404).json({ error: "Stock-out record not found." });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const sale = rows[0];
|
|
154
|
+
const conn = await db.getConnection();
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
await conn.beginTransaction();
|
|
158
|
+
|
|
159
|
+
// Restore the batch's remaining quantity
|
|
160
|
+
await conn.query(
|
|
161
|
+
"UPDATE stock_in SET remaining_qty = remaining_qty + ? WHERE id = ?",
|
|
162
|
+
[sale.quantity, sale.stock_in_id]
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Restore the part's total stock
|
|
166
|
+
await conn.query(
|
|
167
|
+
"UPDATE spare_parts SET quantity = quantity + ? WHERE id = ?",
|
|
168
|
+
[sale.quantity, sale.spare_part_id]
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
await conn.query("DELETE FROM stock_out WHERE id = ?", [sale.id]);
|
|
172
|
+
|
|
173
|
+
await conn.commit();
|
|
174
|
+
|
|
175
|
+
res.json({ message: "Stock-out record deleted and quantity restored." });
|
|
176
|
+
|
|
177
|
+
} catch (err) {
|
|
178
|
+
await conn.rollback();
|
|
179
|
+
throw err;
|
|
180
|
+
} finally {
|
|
181
|
+
conn.release();
|
|
182
|
+
}
|
|
183
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
-- Run once to set up the database:
|
|
2
|
+
-- mysql -u root -p < schema.sql
|
|
3
|
+
|
|
4
|
+
CREATE DATABASE IF NOT EXISTS spareparts_db;
|
|
5
|
+
USE spareparts_db;
|
|
6
|
+
|
|
7
|
+
-- ─── Spare Parts ──────────────────────────────────────────────────────────────
|
|
8
|
+
-- Master catalogue of every part.
|
|
9
|
+
-- `quantity` is the current stock level — updated by stock-in and stock-out.
|
|
10
|
+
CREATE TABLE IF NOT EXISTS spare_parts (
|
|
11
|
+
id INT PRIMARY KEY AUTO_INCREMENT,
|
|
12
|
+
name VARCHAR(150) NOT NULL,
|
|
13
|
+
quantity INT NOT NULL DEFAULT 0, -- current total stock on hand
|
|
14
|
+
unit_price DECIMAL(12,2) NOT NULL,
|
|
15
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
-- ─── Stock In ─────────────────────────────────────────────────────────────────
|
|
19
|
+
-- Each row records one import/purchase transaction.
|
|
20
|
+
-- `remaining_qty` tracks how many units from this specific batch are still
|
|
21
|
+
-- available to sell — decremented by stock-out operations (FIFO model).
|
|
22
|
+
CREATE TABLE IF NOT EXISTS stock_in (
|
|
23
|
+
id INT PRIMARY KEY AUTO_INCREMENT,
|
|
24
|
+
spare_part_id INT NOT NULL,
|
|
25
|
+
quantity INT NOT NULL, -- units imported in this batch
|
|
26
|
+
remaining_qty INT NOT NULL, -- units still available from this batch
|
|
27
|
+
unit_price DECIMAL(12,2) NOT NULL, -- price paid per unit at import
|
|
28
|
+
total_price DECIMAL(12,2) NOT NULL, -- quantity × unit_price
|
|
29
|
+
imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
30
|
+
FOREIGN KEY (spare_part_id) REFERENCES spare_parts(id) ON DELETE CASCADE
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
-- ─── Stock Out ────────────────────────────────────────────────────────────────
|
|
34
|
+
-- Each row records one sale transaction drawn from a specific stock_in batch.
|
|
35
|
+
CREATE TABLE IF NOT EXISTS stock_out (
|
|
36
|
+
id INT PRIMARY KEY AUTO_INCREMENT,
|
|
37
|
+
stock_in_id INT NOT NULL, -- which batch was sold from
|
|
38
|
+
spare_part_id INT NOT NULL, -- denormalised for easy reporting
|
|
39
|
+
quantity INT NOT NULL, -- units sold
|
|
40
|
+
unit_price DECIMAL(12,2) NOT NULL, -- selling price per unit
|
|
41
|
+
total_price DECIMAL(12,2) NOT NULL, -- quantity × unit_price
|
|
42
|
+
sold_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
43
|
+
FOREIGN KEY (stock_in_id) REFERENCES stock_in(id) ON DELETE CASCADE,
|
|
44
|
+
FOREIGN KEY (spare_part_id) REFERENCES spare_parts(id) ON DELETE CASCADE
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
-- ─── Seed data ────────────────────────────────────────────────────────────────
|
|
48
|
+
INSERT IGNORE INTO spare_parts (name, quantity, unit_price) VALUES
|
|
49
|
+
('Brake Pads', 50, 12000),
|
|
50
|
+
('Oil Filter', 80, 4500),
|
|
51
|
+
('Air Filter', 60, 5800),
|
|
52
|
+
('Spark Plugs', 120, 2200),
|
|
53
|
+
('Timing Belt', 30, 18000);
|