project-startup 1.0.2 → 1.1.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-startup",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
4
4
  "description": "Minimal session-based auth starter using Express, MySQL, React, Vite, and React Router.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -1,41 +1,23 @@
1
1
  const db = require("../config/db");
2
2
 
3
3
  // GET /api/bookings
4
- // Manager → all bookings with customer + bus details
5
- // Customer → only their own bookings
6
4
  exports.getBookings = async (req, res) => {
7
5
  if (req.user.role === "manager") {
8
6
  const [bookings] = await db.query(
9
- `SELECT
10
- bk.id,
11
- bk.seats,
12
- bk.total_rwf,
13
- bk.booked_at,
14
- u.name AS customer_name,
15
- u.email AS customer_email,
16
- b.plate_number,
17
- b.destination,
18
- b.departure_time,
19
- b.price_rwf
20
- FROM bookings bk
21
- JOIN users u ON u.id = bk.customer_id
22
- JOIN buses b ON b.id = bk.bus_id
23
- ORDER BY bk.booked_at DESC`
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`
24
14
  );
25
15
  return res.json(bookings);
26
16
  }
27
17
 
28
- // Customer: only their rows
29
18
  const [bookings] = await db.query(
30
- `SELECT
31
- bk.id,
32
- bk.seats,
33
- bk.total_rwf,
34
- bk.booked_at,
35
- b.plate_number,
36
- b.destination,
37
- b.departure_time,
38
- b.price_rwf
19
+ `SELECT bk.id, bk.seats, bk.total_rwf, bk.booked_at,
20
+ b.plate_number, b.destination, b.departure_time, b.price_rwf
39
21
  FROM bookings bk
40
22
  JOIN buses b ON b.id = bk.bus_id
41
23
  WHERE bk.customer_id = ?
@@ -46,131 +28,90 @@ exports.getBookings = async (req, res) => {
46
28
  res.json(bookings);
47
29
  };
48
30
 
49
- // POST /api/bookings (customer only)
31
+ // POST /api/bookings (customer only) — SIMPLIFIED
50
32
  exports.createBooking = async (req, res) => {
51
33
  const { busId, seats } = req.body;
52
34
 
53
35
  if (!busId || !seats || seats < 1) {
54
- return res.status(400).json({ error: "busId and seats (≥ 1) are required." });
36
+ return res.status(400).json({ error: "busId and seats required" });
55
37
  }
56
38
 
57
- // ── Critical section: read and update in one atomic step ───────────────
58
- // We use a transaction so two customers can't both grab the last seat.
59
- const conn = await db.getConnection();
39
+ // 1. Get the bus
40
+ const [buses] = await db.query(
41
+ "SELECT * FROM buses WHERE id = ?",
42
+ [busId]
43
+ );
60
44
 
61
- try {
62
- await conn.beginTransaction();
45
+ if (buses.length === 0) {
46
+ return res.status(404).json({ error: "Bus not found" });
47
+ }
63
48
 
64
- // Lock this bus row for the duration of the transaction (FOR UPDATE)
65
- // This prevents another request from reading stale available_seats
66
- const [rows] = await conn.query(
67
- "SELECT * FROM buses WHERE id = ? FOR UPDATE",
68
- [busId]
69
- );
49
+ const bus = buses[0];
70
50
 
71
- if (rows.length === 0) {
72
- await conn.rollback();
73
- return res.status(404).json({ error: "Bus not found." });
74
- }
75
-
76
- const bus = rows[0];
77
-
78
- if (new Date(bus.departure_time) <= new Date()) {
79
- await conn.rollback();
80
- return res.status(400).json({ error: "This bus has already departed." });
81
- }
82
-
83
- if (seats > bus.available_seats) {
84
- await conn.rollback();
85
- return res.status(400).json({
86
- error: `Only ${bus.available_seats} seat(s) available.`,
87
- });
88
- }
89
-
90
- // Decrement available_seats
91
- await conn.query(
92
- "UPDATE buses SET available_seats = available_seats - ? WHERE id = ?",
93
- [seats, busId]
94
- );
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
+ }
95
55
 
96
- // Insert the booking record
97
- const [result] = await conn.query(
98
- `INSERT INTO bookings (bus_id, customer_id, seats, total_rwf)
99
- VALUES (?, ?, ?, ?)`,
100
- [busId, req.user.id, seats, seats * bus.price_rwf]
101
- );
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
+ }
102
62
 
103
- await conn.commit();
104
-
105
- // Return the new booking with bus details attached
106
- const [booking] = await conn.query(
107
- `SELECT
108
- bk.id,
109
- bk.seats,
110
- bk.total_rwf,
111
- bk.booked_at,
112
- b.plate_number,
113
- b.destination,
114
- b.departure_time
115
- FROM bookings bk
116
- JOIN buses b ON b.id = bk.bus_id
117
- WHERE bk.id = ?`,
118
- [result.insertId]
119
- );
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
+ );
120
68
 
121
- res.status(201).json(booking[0]);
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
+ );
122
74
 
123
- } catch (err) {
124
- await conn.rollback();
125
- throw err;
126
- } finally {
127
- conn.release(); // always return connection to pool
128
- }
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
+ });
129
86
  };
130
87
 
131
- // DELETE /api/bookings/:id (customer only own bookings)
88
+ // DELETE /api/bookings/:id — SIMPLIFIED
132
89
  exports.cancelBooking = async (req, res) => {
133
- const conn = await db.getConnection();
134
-
135
- try {
136
- await conn.beginTransaction();
137
-
138
- // Find the booking AND confirm it belongs to this customer
139
- const [rows] = await conn.query(
140
- "SELECT * FROM bookings WHERE id = ? AND customer_id = ?",
141
- [req.params.id, req.user.id]
142
- );
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
+ );
143
98
 
144
- if (rows.length === 0) {
145
- await conn.rollback();
146
- return res.status(404).json({ error: "Booking not found." });
147
- }
99
+ if (bookings.length === 0) {
100
+ return res.status(404).json({ error: "Booking not found" });
101
+ }
148
102
 
149
- const booking = rows[0];
103
+ const booking = bookings[0];
150
104
 
151
- // Restore seats only if bus hasn't departed yet
152
- const [busRows] = await conn.query(
153
- "SELECT departure_time FROM buses WHERE id = ?",
154
- [booking.bus_id]
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]
155
110
  );
111
+ }
156
112
 
157
- if (busRows.length > 0 && new Date(busRows[0].departure_time) > new Date()) {
158
- await conn.query(
159
- "UPDATE buses SET available_seats = available_seats + ? WHERE id = ?",
160
- [booking.seats, booking.bus_id]
161
- );
162
- }
163
-
164
- await conn.query("DELETE FROM bookings WHERE id = ?", [booking.id]);
165
-
166
- await conn.commit();
167
-
168
- res.json({ message: "Booking cancelled. Seats have been restored." });
113
+ // 3. Delete booking
114
+ await db.query("DELETE FROM bookings WHERE id = ?", [req.params.id]);
169
115
 
170
- } catch (err) {
171
- await conn.rollback();
172
- throw err;
173
- } finally {
174
- conn.release();
175
- }
176
- };
116
+ res.json({ message: "Booking cancelled" });
117
+ };
@@ -10,124 +10,76 @@ exports.getAllBuses = async (req, res) => {
10
10
 
11
11
  // GET /api/buses/:id
12
12
  exports.getBusById = async (req, res) => {
13
- const [rows] = await db.query(
13
+ const [buses] = await db.query(
14
14
  "SELECT * FROM buses WHERE id = ?",
15
15
  [req.params.id]
16
16
  );
17
17
 
18
- if (rows.length === 0) {
19
- return res.status(404).json({ error: "Bus not found." });
18
+ if (buses.length === 0) {
19
+ return res.status(404).json({ error: "Bus not found" });
20
20
  }
21
21
 
22
- res.json(rows[0]);
22
+ res.json(buses[0]);
23
23
  };
24
24
 
25
- // POST /api/buses (manager only)
25
+ // POST /api/buses (manager only) — SIMPLIFIED
26
26
  exports.createBus = async (req, res) => {
27
27
  const { plateNumber, destination, maxSeats, departureTime, priceRwf } = req.body;
28
28
 
29
29
  if (!plateNumber || !destination || !maxSeats || !departureTime || !priceRwf) {
30
- return res.status(400).json({ error: "All fields are required." });
31
- }
32
-
33
- if (new Date(departureTime) <= new Date()) {
34
- return res.status(400).json({ error: "Departure time must be in the future." });
30
+ return res.status(400).json({ error: "All fields required" });
35
31
  }
36
32
 
37
- // Check for duplicate plate number
38
- const [existing] = await db.query(
39
- "SELECT id FROM buses WHERE plate_number = ?",
40
- [plateNumber]
41
- );
42
-
43
- if (existing.length > 0) {
44
- return res.status(400).json({ error: "Plate number already exists." });
33
+ if (new Date(deploymentTime) <= new Date()) {
34
+ return res.status(400).json({ error: "Departure time must be in future" });
45
35
  }
46
36
 
47
37
  const [result] = await db.query(
48
- `INSERT INTO buses
49
- (plate_number, destination, max_seats, available_seats, departure_time, price_rwf)
38
+ `INSERT INTO buses
39
+ (plate_number, destination, max_seats, available_seats, departure_time, price_rwf)
50
40
  VALUES (?, ?, ?, ?, ?, ?)`,
51
41
  [plateNumber, destination, maxSeats, maxSeats, departureTime, priceRwf]
52
- // ^ available_seats starts equal to max_seats
53
42
  );
54
43
 
55
- // Fetch and return the newly created row
56
- const [rows] = await db.query(
44
+ const [buses] = await db.query(
57
45
  "SELECT * FROM buses WHERE id = ?",
58
46
  [result.insertId]
59
47
  );
60
48
 
61
- res.status(201).json(rows[0]);
49
+ res.status(201).json(buses[0]);
62
50
  };
63
51
 
64
- // PUT /api/buses/:id (manager only)
52
+ // PUT /api/buses/:id (manager only) — SIMPLIFIED
65
53
  exports.updateBus = async (req, res) => {
66
- const { plateNumber, destination, maxSeats, departureTime, priceRwf } = req.body;
67
-
68
- // Fetch current state first so we can calculate available_seats correctly
69
- const [rows] = await db.query(
70
- "SELECT * FROM buses WHERE id = ?",
71
- [req.params.id]
72
- );
73
-
74
- if (rows.length === 0) {
75
- return res.status(404).json({ error: "Bus not found." });
76
- }
77
-
78
- const bus = rows[0];
79
-
80
- // If maxSeats is being changed, recalculate available_seats:
81
- // available = newMax - (oldMax - oldAvailable) ← keeps already-booked seats
82
- let newAvailable = bus.available_seats;
83
-
84
- if (maxSeats) {
85
- const booked = bus.max_seats - bus.available_seats;
86
- if (parseInt(maxSeats) < booked) {
87
- return res.status(400).json({
88
- error: `Cannot reduce max seats below already booked count (${booked}).`,
89
- });
90
- }
91
- newAvailable = parseInt(maxSeats) - booked;
92
- }
54
+ const { plateNumber, destination, departureTime, priceRwf } = req.body;
93
55
 
94
56
  await db.query(
95
57
  `UPDATE buses SET
96
- plate_number = COALESCE(?, plate_number),
97
- destination = COALESCE(?, destination),
98
- max_seats = COALESCE(?, max_seats),
99
- available_seats = ?,
100
- departure_time = COALESCE(?, departure_time),
101
- price_rwf = COALESCE(?, price_rwf)
58
+ plate_number = COALESCE(?, plate_number),
59
+ destination = COALESCE(?, destination),
60
+ departure_time = COALESCE(?, departure_time),
61
+ price_rwf = COALESCE(?, price_rwf)
102
62
  WHERE id = ?`,
103
- [
104
- plateNumber || null,
105
- destination || null,
106
- maxSeats || null,
107
- newAvailable,
108
- departureTime|| null,
109
- priceRwf || null,
110
- req.params.id,
111
- ]
63
+ [plateNumber, destination, departureTime, priceRwf, req.params.id]
112
64
  );
113
65
 
114
- // Return updated row
115
- const [updated] = await db.query("SELECT * FROM buses WHERE id = ?", [req.params.id]);
116
- res.json(updated[0]);
66
+ const [buses] = await db.query(
67
+ "SELECT * FROM buses WHERE id = ?",
68
+ [req.params.id]
69
+ );
70
+
71
+ res.json(buses[0]);
117
72
  };
118
73
 
119
- // DELETE /api/buses/:id (manager only)
74
+ // DELETE /api/buses/:id (manager only)
120
75
  exports.deleteBus = async (req, res) => {
121
- // Check if any bookings exist for this bus
122
76
  const [bookings] = await db.query(
123
77
  "SELECT id FROM bookings WHERE bus_id = ?",
124
78
  [req.params.id]
125
79
  );
126
80
 
127
81
  if (bookings.length > 0) {
128
- return res.status(400).json({
129
- error: "Cannot delete a bus that has existing bookings.",
130
- });
82
+ return res.status(400).json({ error: "Cannot delete bus with bookings" });
131
83
  }
132
84
 
133
85
  const [result] = await db.query(
@@ -136,8 +88,8 @@ exports.deleteBus = async (req, res) => {
136
88
  );
137
89
 
138
90
  if (result.affectedRows === 0) {
139
- return res.status(404).json({ error: "Bus not found." });
91
+ return res.status(404).json({ error: "Bus not found" });
140
92
  }
141
93
 
142
- res.json({ message: "Bus deleted." });
143
- };
94
+ res.json({ message: "Bus deleted" });
95
+ };
@@ -0,0 +1,72 @@
1
+ const db = require("../db");
2
+
3
+ // GET /api/parts
4
+ exports.getAllParts = async (req, res) => {
5
+ const [parts] = await db.query("SELECT * FROM spare_parts ORDER BY name ASC");
6
+ res.json(parts);
7
+ };
8
+
9
+ // GET /api/parts/:id
10
+ exports.getPartById = async (req, res) => {
11
+ const [parts] = await db.query("SELECT * FROM spare_parts WHERE id = ?", [req.params.id]);
12
+
13
+ if (parts.length === 0) {
14
+ return res.status(404).json({ error: "Spare part not found" });
15
+ }
16
+
17
+ res.json(parts[0]);
18
+ };
19
+
20
+ // POST /api/parts
21
+ exports.createPart = async (req, res) => {
22
+ const { name, quantity, unitPrice } = req.body;
23
+
24
+ if (!name || quantity == null || !unitPrice) {
25
+ return res.status(400).json({ error: "name, quantity and unitPrice required" });
26
+ }
27
+
28
+ if (quantity < 0) {
29
+ return res.status(400).json({ error: "Quantity cannot be negative" });
30
+ }
31
+
32
+ const [result] = await db.query(
33
+ "INSERT INTO spare_parts (name, quantity, unit_price) VALUES (?, ?, ?)",
34
+ [name.trim(), quantity, unitPrice]
35
+ );
36
+
37
+ const [parts] = await db.query("SELECT * FROM spare_parts WHERE id = ?", [result.insertId]);
38
+ res.status(201).json(parts[0]);
39
+ };
40
+
41
+ // PUT /api/parts/:id
42
+ exports.updatePart = async (req, res) => {
43
+ const [parts] = await db.query("SELECT * FROM spare_parts WHERE id = ?", [req.params.id]);
44
+
45
+ if (parts.length === 0) {
46
+ return res.status(404).json({ error: "Spare part not found" });
47
+ }
48
+
49
+ const { name, unitPrice } = req.body;
50
+
51
+ await db.query(
52
+ `UPDATE spare_parts SET
53
+ name = COALESCE(?, name),
54
+ unit_price = COALESCE(?, unit_price)
55
+ WHERE id = ?`,
56
+ [name || null, unitPrice || null, req.params.id]
57
+ );
58
+
59
+ const [updated] = await db.query("SELECT * FROM spare_parts WHERE id = ?", [req.params.id]);
60
+ res.json(updated[0]);
61
+ };
62
+
63
+ // DELETE /api/parts/:id — SIMPLIFIED (removed stock-in check)
64
+ exports.deletePart = async (req, res) => {
65
+ const [result] = await db.query("DELETE FROM spare_parts WHERE id = ?", [req.params.id]);
66
+
67
+ if (result.affectedRows === 0) {
68
+ return res.status(404).json({ error: "Spare part not found" });
69
+ }
70
+
71
+ res.json({ message: "Spare part deleted" });
72
+ };
@@ -0,0 +1,96 @@
1
+ const db = require("../db");
2
+
3
+ // GET /api/stock-in
4
+ exports.getAllStockIn = async (req, res) => {
5
+ const [rows] = await db.query(
6
+ `SELECT si.id, si.quantity, si.remaining_qty, si.unit_price, si.total_price,
7
+ si.imported_at, sp.id AS spare_part_id, sp.name AS spare_part_name
8
+ FROM stock_in si
9
+ JOIN spare_parts sp ON sp.id = si.spare_part_id
10
+ ORDER BY si.imported_at DESC`
11
+ );
12
+ res.json(rows);
13
+ };
14
+
15
+ // GET /api/stock-in/:id
16
+ exports.getStockInById = async (req, res) => {
17
+ const [rows] = await db.query(
18
+ `SELECT si.*, sp.name AS spare_part_name
19
+ FROM stock_in si
20
+ JOIN spare_parts sp ON sp.id = si.spare_part_id
21
+ WHERE si.id = ?`,
22
+ [req.params.id]
23
+ );
24
+
25
+ if (rows.length === 0) {
26
+ return res.status(404).json({ error: "Stock-in record not found" });
27
+ }
28
+
29
+ res.json(rows[0]);
30
+ };
31
+
32
+ // POST /api/stock-in — SIMPLIFIED (no transaction)
33
+ exports.createStockIn = async (req, res) => {
34
+ const { sparePartId, quantity, unitPrice } = req.body;
35
+
36
+ if (!sparePartId || !quantity || !unitPrice) {
37
+ return res.status(400).json({ error: "sparePartId, quantity and unitPrice required" });
38
+ }
39
+
40
+ if (quantity < 1) {
41
+ return res.status(400).json({ error: "Quantity must be at least 1" });
42
+ }
43
+
44
+ // Verify part exists
45
+ const [parts] = await db.query("SELECT * FROM spare_parts WHERE id = ?", [sparePartId]);
46
+ if (parts.length === 0) {
47
+ return res.status(404).json({ error: "Spare part not found" });
48
+ }
49
+
50
+ const totalPrice = quantity * unitPrice;
51
+
52
+ // Insert stock-in batch
53
+ const [result] = await db.query(
54
+ `INSERT INTO stock_in (spare_part_id, quantity, remaining_qty, unit_price, total_price)
55
+ VALUES (?, ?, ?, ?, ?)`,
56
+ [sparePartId, quantity, quantity, unitPrice, totalPrice]
57
+ );
58
+
59
+ // Increment part's total stock
60
+ await db.query(
61
+ "UPDATE spare_parts SET quantity = quantity + ? WHERE id = ?",
62
+ [quantity, sparePartId]
63
+ );
64
+
65
+ // Return full record with part name
66
+ const [rows] = await db.query(
67
+ `SELECT si.*, sp.name AS spare_part_name
68
+ FROM stock_in si
69
+ JOIN spare_parts sp ON sp.id = si.spare_part_id
70
+ WHERE si.id = ?`,
71
+ [result.insertId]
72
+ );
73
+
74
+ res.status(201).json(rows[0]);
75
+ };
76
+
77
+ // DELETE /api/stock-in/:id — SIMPLIFIED (no transaction, no stock-out check)
78
+ exports.deleteStockIn = async (req, res) => {
79
+ const [rows] = await db.query("SELECT * FROM stock_in WHERE id = ?", [req.params.id]);
80
+
81
+ if (rows.length === 0) {
82
+ return res.status(404).json({ error: "Stock-in record not found" });
83
+ }
84
+
85
+ const stockIn = rows[0];
86
+
87
+ // Give quantity back to spare part
88
+ await db.query(
89
+ "UPDATE spare_parts SET quantity = quantity - ? WHERE id = ?",
90
+ [stockIn.quantity, stockIn.spare_part_id]
91
+ );
92
+
93
+ await db.query("DELETE FROM stock_in WHERE id = ?", [req.params.id]);
94
+
95
+ res.json({ message: "Stock-in deleted and quantity reversed" });
96
+ };
@@ -0,0 +1,128 @@
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 so.id, so.quantity, so.unit_price, so.total_price, so.sold_at,
7
+ sp.id AS spare_part_id, sp.name AS spare_part_name,
8
+ si.id AS stock_in_id, si.unit_price AS import_unit_price, si.imported_at
9
+ FROM stock_out so
10
+ JOIN stock_in si ON si.id = so.stock_in_id
11
+ JOIN spare_parts sp ON sp.id = so.spare_part_id
12
+ ORDER BY so.sold_at DESC`
13
+ );
14
+ res.json(rows);
15
+ };
16
+
17
+ // GET /api/stock-out/:id
18
+ exports.getStockOutById = async (req, res) => {
19
+ const [rows] = await db.query(
20
+ `SELECT so.*, sp.name AS spare_part_name, si.remaining_qty, si.unit_price AS import_unit_price
21
+ FROM stock_out so
22
+ JOIN stock_in si ON si.id = so.stock_in_id
23
+ JOIN spare_parts sp ON sp.id = so.spare_part_id
24
+ WHERE so.id = ?`,
25
+ [req.params.id]
26
+ );
27
+
28
+ if (rows.length === 0) {
29
+ return res.status(404).json({ error: "Stock-out record not found" });
30
+ }
31
+
32
+ res.json(rows[0]);
33
+ };
34
+
35
+ // POST /api/stock-out — SIMPLIFIED (no transaction, no FOR UPDATE lock)
36
+ exports.createStockOut = async (req, res) => {
37
+ const { stockInId, quantity, unitPrice } = req.body;
38
+
39
+ if (!stockInId || !quantity || !unitPrice) {
40
+ return res.status(400).json({ error: "stockInId, quantity and unitPrice required" });
41
+ }
42
+
43
+ if (quantity < 1) {
44
+ return res.status(400).json({ error: "Quantity must be at least 1" });
45
+ }
46
+
47
+ // Get the stock-in batch
48
+ const [siRows] = await db.query(
49
+ `SELECT si.*, sp.id AS part_id
50
+ FROM stock_in si
51
+ JOIN spare_parts sp ON sp.id = si.spare_part_id
52
+ WHERE si.id = ?`,
53
+ [stockInId]
54
+ );
55
+
56
+ if (siRows.length === 0) {
57
+ return res.status(404).json({ error: "Stock-in batch not found" });
58
+ }
59
+
60
+ const batch = siRows[0];
61
+
62
+ // Check remaining quantity (simple check, no lock)
63
+ if (quantity > batch.remaining_qty) {
64
+ return res.status(400).json({
65
+ error: `Only ${batch.remaining_qty} unit(s) remaining in this batch`
66
+ });
67
+ }
68
+
69
+ const totalPrice = quantity * unitPrice;
70
+
71
+ // Insert sale record
72
+ const [result] = await db.query(
73
+ `INSERT INTO stock_out (stock_in_id, spare_part_id, quantity, unit_price, total_price)
74
+ VALUES (?, ?, ?, ?, ?)`,
75
+ [stockInId, batch.part_id, quantity, unitPrice, totalPrice]
76
+ );
77
+
78
+ // Decrement remaining units in batch
79
+ await db.query(
80
+ "UPDATE stock_in SET remaining_qty = remaining_qty - ? WHERE id = ?",
81
+ [quantity, stockInId]
82
+ );
83
+
84
+ // Decrement part's total stock
85
+ await db.query(
86
+ "UPDATE spare_parts SET quantity = quantity - ? WHERE id = ?",
87
+ [quantity, batch.part_id]
88
+ );
89
+
90
+ // Return full record
91
+ const [rows] = await db.query(
92
+ `SELECT so.*, sp.name AS spare_part_name, si.unit_price AS import_unit_price, si.remaining_qty
93
+ FROM stock_out so
94
+ JOIN stock_in si ON si.id = so.stock_in_id
95
+ JOIN spare_parts sp ON sp.id = so.spare_part_id
96
+ WHERE so.id = ?`,
97
+ [result.insertId]
98
+ );
99
+
100
+ res.status(201).json(rows[0]);
101
+ };
102
+
103
+ // DELETE /api/stock-out/:id — SIMPLIFIED (no transaction)
104
+ exports.deleteStockOut = async (req, res) => {
105
+ const [rows] = await db.query("SELECT * FROM stock_out WHERE id = ?", [req.params.id]);
106
+
107
+ if (rows.length === 0) {
108
+ return res.status(404).json({ error: "Stock-out record not found" });
109
+ }
110
+
111
+ const sale = rows[0];
112
+
113
+ // Restore batch's remaining quantity
114
+ await db.query(
115
+ "UPDATE stock_in SET remaining_qty = remaining_qty + ? WHERE id = ?",
116
+ [sale.quantity, sale.stock_in_id]
117
+ );
118
+
119
+ // Restore part's total stock
120
+ await db.query(
121
+ "UPDATE spare_parts SET quantity = quantity + ? WHERE id = ?",
122
+ [sale.quantity, sale.spare_part_id]
123
+ );
124
+
125
+ await db.query("DELETE FROM stock_out WHERE id = ?", [sale.id]);
126
+
127
+ res.json({ message: "Stock-out deleted and quantity restored" });
128
+ };
@@ -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);