View | Details | Raw Unified | Return to bug 37707
Collapse All | Expand All

(-)a/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js (-39 / +299 lines)
Lines 1135-1140 $("#placeBookingModal").on("show.bs.modal", function (e) { Link Here
1135
                                withBooking: false,
1135
                                withBooking: false,
1136
                            };
1136
                            };
1137
1137
1138
                            // For "any item" mode, we need to check if at least one item
1139
                            // of the selected itemtype is free from lead/trail conflicts.
1140
                            // For specific item mode, we use the original single-item logic.
1141
                            const isAnyItemMode =
1142
                                !booking_item_id && booking_itemtype_id;
1143
1144
                            // Get items of the selected itemtype for "any item" mode
1145
                            let itemsOfSelectedType = [];
1146
                            if (isAnyItemMode) {
1147
                                itemsOfSelectedType = bookable_items.filter(
1148
                                    item =>
1149
                                        item.effective_item_type_id ===
1150
                                        booking_itemtype_id
1151
                                );
1152
                            }
1153
1154
                            // Track per-item conflicts for "any item" mode
1155
                            // Maps item_id -> { leadConflict: bool, trailConflict: bool, leadReason: {...}, trailReason: {...} }
1156
                            const itemConflicts = new Map();
1157
                            if (isAnyItemMode) {
1158
                                itemsOfSelectedType.forEach(item => {
1159
                                    itemConflicts.set(
1160
                                        parseInt(item.item_id, 10),
1161
                                        {
1162
                                            leadConflict: false,
1163
                                            trailConflict: false,
1164
                                            leadReason: {
1165
                                                withTrail: false,
1166
                                                withLead: false,
1167
                                                withBooking: false,
1168
                                            },
1169
                                            trailReason: {
1170
                                                withTrail: false,
1171
                                                withLead: false,
1172
                                                withBooking: false,
1173
                                            },
1174
                                        }
1175
                                    );
1176
                                });
1177
                            }
1178
1138
                            bookings.forEach(booking => {
1179
                            bookings.forEach(booking => {
1139
                                // Skip if we're editing this booking
1180
                                // Skip if we're editing this booking
1140
                                if (
1181
                                if (
Lines 1144-1156 $("#placeBookingModal").on("show.bs.modal", function (e) { Link Here
1144
                                    return;
1185
                                    return;
1145
                                }
1186
                                }
1146
1187
1147
                                // Skip if not same item (for item-specific bookings)
1188
                                const bookingItemId = parseInt(
1148
                                if (
1189
                                    booking.item_id,
1149
                                    booking.item_id &&
1190
                                    10
1150
                                    booking_item_id &&
1191
                                );
1151
                                    booking.item_id != booking_item_id
1192
1152
                                ) {
1193
                                // For specific item mode: skip bookings for different items
1153
                                    return;
1194
                                if (!isAnyItemMode) {
1195
                                    if (
1196
                                        booking.item_id &&
1197
                                        booking_item_id &&
1198
                                        bookingItemId !==
1199
                                            parseInt(booking_item_id, 10)
1200
                                    ) {
1201
                                        return;
1202
                                    }
1203
                                } else {
1204
                                    // For "any item" mode: skip bookings for items not of the selected itemtype
1205
                                    if (!itemConflicts.has(bookingItemId)) {
1206
                                        return;
1207
                                    }
1154
                                }
1208
                                }
1155
1209
1156
                                const bookingStart = dayjs(
1210
                                const bookingStart = dayjs(
Lines 1181-1186 $("#placeBookingModal").on("show.bs.modal", function (e) { Link Here
1181
1235
1182
                                // Check if new booking's LEAD period overlaps with existing booking
1236
                                // Check if new booking's LEAD period overlaps with existing booking
1183
                                if (!periodPicker.selectedDates[0]) {
1237
                                if (!periodPicker.selectedDates[0]) {
1238
                                    let hasLeadConflict = false;
1239
                                    let reason = {
1240
                                        withTrail: false,
1241
                                        withLead: false,
1242
                                        withBooking: false,
1243
                                    };
1244
1184
                                    // Check overlap with existing booking's trail period
1245
                                    // Check overlap with existing booking's trail period
1185
                                    if (
1246
                                    if (
1186
                                        leadStart.isSameOrBefore(
1247
                                        leadStart.isSameOrBefore(
Lines 1190-1197 $("#placeBookingModal").on("show.bs.modal", function (e) { Link Here
1190
                                            existingTrailStart
1251
                                            existingTrailStart
1191
                                        )
1252
                                        )
1192
                                    ) {
1253
                                    ) {
1193
                                        leadDisable = true;
1254
                                        hasLeadConflict = true;
1194
                                        leadConflictReason.withTrail = true;
1255
                                        reason.withTrail = true;
1195
                                    }
1256
                                    }
1196
                                    // Check overlap with existing booking's lead period
1257
                                    // Check overlap with existing booking's lead period
1197
                                    else if (
1258
                                    else if (
Lines 1200-1220 $("#placeBookingModal").on("show.bs.modal", function (e) { Link Here
1200
                                        ) &&
1261
                                        ) &&
1201
                                        leadEnd.isSameOrAfter(existingLeadStart)
1262
                                        leadEnd.isSameOrAfter(existingLeadStart)
1202
                                    ) {
1263
                                    ) {
1203
                                        leadDisable = true;
1264
                                        hasLeadConflict = true;
1204
                                        leadConflictReason.withLead = true;
1265
                                        reason.withLead = true;
1205
                                    }
1266
                                    }
1206
                                    // Check overlap with existing booking itself
1267
                                    // Check overlap with existing booking itself
1207
                                    else if (
1268
                                    else if (
1208
                                        leadStart.isSameOrBefore(bookingEnd) &&
1269
                                        leadStart.isSameOrBefore(bookingEnd) &&
1209
                                        leadEnd.isSameOrAfter(bookingStart)
1270
                                        leadEnd.isSameOrAfter(bookingStart)
1210
                                    ) {
1271
                                    ) {
1211
                                        leadDisable = true;
1272
                                        hasLeadConflict = true;
1212
                                        leadConflictReason.withBooking = true;
1273
                                        reason.withBooking = true;
1274
                                    }
1275
1276
                                    if (hasLeadConflict) {
1277
                                        if (isAnyItemMode) {
1278
                                            // Track conflict for this specific item
1279
                                            const itemState =
1280
                                                itemConflicts.get(
1281
                                                    bookingItemId
1282
                                                );
1283
                                            if (itemState) {
1284
                                                itemState.leadConflict = true;
1285
                                                Object.assign(
1286
                                                    itemState.leadReason,
1287
                                                    reason
1288
                                                );
1289
                                            }
1290
                                        } else {
1291
                                            // Specific item mode: set global flags
1292
                                            leadDisable = true;
1293
                                            Object.assign(
1294
                                                leadConflictReason,
1295
                                                reason
1296
                                            );
1297
                                        }
1213
                                    }
1298
                                    }
1214
                                }
1299
                                }
1215
1300
1216
                                // Check if new booking's TRAIL period overlaps with existing booking
1301
                                // Check if new booking's TRAIL period overlaps with existing booking
1217
                                if (periodPicker.selectedDates[0]) {
1302
                                if (periodPicker.selectedDates[0]) {
1303
                                    let hasTrailConflict = false;
1304
                                    let reason = {
1305
                                        withTrail: false,
1306
                                        withLead: false,
1307
                                        withBooking: false,
1308
                                    };
1309
1218
                                    // Check overlap with existing booking's lead period
1310
                                    // Check overlap with existing booking's lead period
1219
                                    if (
1311
                                    if (
1220
                                        trailStart.isSameOrBefore(
1312
                                        trailStart.isSameOrBefore(
Lines 1224-1231 $("#placeBookingModal").on("show.bs.modal", function (e) { Link Here
1224
                                            existingLeadStart
1316
                                            existingLeadStart
1225
                                        )
1317
                                        )
1226
                                    ) {
1318
                                    ) {
1227
                                        trailDisable = true;
1319
                                        hasTrailConflict = true;
1228
                                        trailConflictReason.withLead = true;
1320
                                        reason.withLead = true;
1229
                                    }
1321
                                    }
1230
                                    // Check overlap with existing booking's trail period
1322
                                    // Check overlap with existing booking's trail period
1231
                                    else if (
1323
                                    else if (
Lines 1236-1255 $("#placeBookingModal").on("show.bs.modal", function (e) { Link Here
1236
                                            existingTrailStart
1328
                                            existingTrailStart
1237
                                        )
1329
                                        )
1238
                                    ) {
1330
                                    ) {
1239
                                        trailDisable = true;
1331
                                        hasTrailConflict = true;
1240
                                        trailConflictReason.withTrail = true;
1332
                                        reason.withTrail = true;
1241
                                    }
1333
                                    }
1242
                                    // Check overlap with existing booking itself
1334
                                    // Check overlap with existing booking itself
1243
                                    else if (
1335
                                    else if (
1244
                                        trailStart.isSameOrBefore(bookingEnd) &&
1336
                                        trailStart.isSameOrBefore(bookingEnd) &&
1245
                                        trailEnd.isSameOrAfter(bookingStart)
1337
                                        trailEnd.isSameOrAfter(bookingStart)
1246
                                    ) {
1338
                                    ) {
1247
                                        trailDisable = true;
1339
                                        hasTrailConflict = true;
1248
                                        trailConflictReason.withBooking = true;
1340
                                        reason.withBooking = true;
1341
                                    }
1342
1343
                                    if (hasTrailConflict) {
1344
                                        if (isAnyItemMode) {
1345
                                            // Track conflict for this specific item
1346
                                            const itemState =
1347
                                                itemConflicts.get(
1348
                                                    bookingItemId
1349
                                                );
1350
                                            if (itemState) {
1351
                                                itemState.trailConflict = true;
1352
                                                Object.assign(
1353
                                                    itemState.trailReason,
1354
                                                    reason
1355
                                                );
1356
                                            }
1357
                                        } else {
1358
                                            // Specific item mode: set global flags
1359
                                            trailDisable = true;
1360
                                            Object.assign(
1361
                                                trailConflictReason,
1362
                                                reason
1363
                                            );
1364
                                        }
1249
                                    }
1365
                                    }
1250
                                }
1366
                                }
1251
1367
1252
                                // Find closest bookings for visual feedback (when dates are in view)
1368
                                // Find closest bookings for visual feedback (when dates are in view)
1369
                                // For "any item" mode, only track closest bookings for items of the selected type
1253
                                if (bookingEnd.isBefore(hoverDate)) {
1370
                                if (bookingEnd.isBefore(hoverDate)) {
1254
                                    const distance = hoverDate.diff(
1371
                                    const distance = hoverDate.diff(
1255
                                        bookingEnd,
1372
                                        bookingEnd,
Lines 1279-1284 $("#placeBookingModal").on("show.bs.modal", function (e) { Link Here
1279
                                }
1396
                                }
1280
                            });
1397
                            });
1281
1398
1399
                            // For "any item" mode: only disable if ALL items have conflicts
1400
                            if (isAnyItemMode && itemConflicts.size > 0) {
1401
                                // Check if all items have lead conflicts
1402
                                let allHaveLeadConflict = true;
1403
                                let allHaveTrailConflict = true;
1404
1405
                                for (const [
1406
                                    itemId,
1407
                                    state,
1408
                                ] of itemConflicts.entries()) {
1409
                                    if (!state.leadConflict) {
1410
                                        allHaveLeadConflict = false;
1411
                                    }
1412
                                    if (!state.trailConflict) {
1413
                                        allHaveTrailConflict = false;
1414
                                    }
1415
                                }
1416
1417
                                if (allHaveLeadConflict) {
1418
                                    leadDisable = true;
1419
                                    // Use the reason from the first item with a conflict for messaging
1420
                                    for (const [
1421
                                        itemId,
1422
                                        state,
1423
                                    ] of itemConflicts.entries()) {
1424
                                        if (state.leadConflict) {
1425
                                            Object.assign(
1426
                                                leadConflictReason,
1427
                                                state.leadReason
1428
                                            );
1429
                                            break;
1430
                                        }
1431
                                    }
1432
                                }
1433
1434
                                if (allHaveTrailConflict) {
1435
                                    trailDisable = true;
1436
                                    // Use the reason from the first item with a conflict for messaging
1437
                                    for (const [
1438
                                        itemId,
1439
                                        state,
1440
                                    ] of itemConflicts.entries()) {
1441
                                        if (state.trailConflict) {
1442
                                            Object.assign(
1443
                                                trailConflictReason,
1444
                                                state.trailReason
1445
                                            );
1446
                                            break;
1447
                                        }
1448
                                    }
1449
                                }
1450
                            }
1451
1452
                            // For "any item" mode, find closest "all items booked" dates mathematically
1453
                            // These are dates where ALL items of the itemtype have bookings
1454
                            // Using mathematical search allows detection across month boundaries
1455
                            let closestFullyBookedBefore = null;
1456
                            let closestFullyBookedAfter = null;
1457
1458
                            if (
1459
                                isAnyItemMode &&
1460
                                itemsOfSelectedType.length > 0
1461
                            ) {
1462
                                const searchLimit = 180; // Days to search in each direction
1463
1464
                                // Search backwards for closest fully-booked date
1465
                                for (let i = 1; i <= searchLimit; i++) {
1466
                                    const checkDate = hoverDate.subtract(
1467
                                        i,
1468
                                        "day"
1469
                                    );
1470
                                    const availableItems =
1471
                                        getAvailableItemsOnDate(
1472
                                            checkDate.toDate(),
1473
                                            itemsOfSelectedType
1474
                                        );
1475
                                    if (availableItems.length === 0) {
1476
                                        closestFullyBookedBefore = checkDate;
1477
                                        break;
1478
                                    }
1479
                                }
1480
1481
                                // Search forwards for closest fully-booked date
1482
                                for (let i = 1; i <= searchLimit; i++) {
1483
                                    const checkDate = hoverDate.add(i, "day");
1484
                                    const availableItems =
1485
                                        getAvailableItemsOnDate(
1486
                                            checkDate.toDate(),
1487
                                            itemsOfSelectedType
1488
                                        );
1489
                                    if (availableItems.length === 0) {
1490
                                        closestFullyBookedAfter = checkDate;
1491
                                        break;
1492
                                    }
1493
                                }
1494
                            }
1495
1282
                            // Work through all days in view and add classes appropraitely based on hovered date
1496
                            // Work through all days in view and add classes appropraitely based on hovered date
1283
                            periodPicker.calendarContainer
1497
                            periodPicker.calendarContainer
1284
                                .querySelectorAll(".flatpickr-day")
1498
                                .querySelectorAll(".flatpickr-day")
Lines 1346-1363 $("#placeBookingModal").on("show.bs.modal", function (e) { Link Here
1346
                                        );
1560
                                        );
1347
                                    }
1561
                                    }
1348
1562
1349
                                    // Show closest preceding existing booking's trail period
1563
                                    // Show closest preceding booking's trail period
1350
                                    if (closestBeforeBooking && trailDays > 0) {
1564
                                    // For "any item" mode, use closest fully-booked date; for specific item, use closest booking
1565
                                    const useClosestFullyBookedForTrail =
1566
                                        isAnyItemMode &&
1567
                                        closestFullyBookedBefore;
1568
                                    const useClosestBookingForTrail =
1569
                                        !isAnyItemMode && closestBeforeBooking;
1570
1571
                                    if (
1572
                                        trailDays > 0 &&
1573
                                        (useClosestFullyBookedForTrail ||
1574
                                            useClosestBookingForTrail)
1575
                                    ) {
1351
                                        const existingTrailStart =
1576
                                        const existingTrailStart =
1352
                                            closestBeforeBooking.end.add(
1577
                                            useClosestFullyBookedForTrail
1353
                                                1,
1578
                                                ? closestFullyBookedBefore.add(
1354
                                                "day"
1579
                                                      1,
1355
                                            );
1580
                                                      "day"
1581
                                                  )
1582
                                                : closestBeforeBooking.end.add(
1583
                                                      1,
1584
                                                      "day"
1585
                                                  );
1356
                                        const existingTrailEnd =
1586
                                        const existingTrailEnd =
1357
                                            closestBeforeBooking.end.add(
1587
                                            useClosestFullyBookedForTrail
1358
                                                trailDays,
1588
                                                ? closestFullyBookedBefore.add(
1359
                                                "day"
1589
                                                      trailDays,
1360
                                            );
1590
                                                      "day"
1591
                                                  )
1592
                                                : closestBeforeBooking.end.add(
1593
                                                      trailDays,
1594
                                                      "day"
1595
                                                  );
1361
1596
1362
                                        if (
1597
                                        if (
1363
                                            elemDate.isSameOrAfter(
1598
                                            elemDate.isSameOrAfter(
Lines 1392-1409 $("#placeBookingModal").on("show.bs.modal", function (e) { Link Here
1392
                                        }
1627
                                        }
1393
                                    }
1628
                                    }
1394
1629
1395
                                    // Show closest following existing booking's lead period
1630
                                    // Show closest following booking's lead period
1396
                                    if (closestAfterBooking && leadDays > 0) {
1631
                                    // For "any item" mode, use closest fully-booked date; for specific item, use closest booking
1632
                                    const useClosestFullyBookedForLead =
1633
                                        isAnyItemMode &&
1634
                                        closestFullyBookedAfter;
1635
                                    const useClosestBookingForLead =
1636
                                        !isAnyItemMode && closestAfterBooking;
1637
1638
                                    if (
1639
                                        leadDays > 0 &&
1640
                                        (useClosestFullyBookedForLead ||
1641
                                            useClosestBookingForLead)
1642
                                    ) {
1397
                                        const existingLeadStart =
1643
                                        const existingLeadStart =
1398
                                            closestAfterBooking.start.subtract(
1644
                                            useClosestFullyBookedForLead
1399
                                                leadDays,
1645
                                                ? closestFullyBookedAfter.subtract(
1400
                                                "day"
1646
                                                      leadDays,
1401
                                            );
1647
                                                      "day"
1648
                                                  )
1649
                                                : closestAfterBooking.start.subtract(
1650
                                                      leadDays,
1651
                                                      "day"
1652
                                                  );
1402
                                        const existingLeadEnd =
1653
                                        const existingLeadEnd =
1403
                                            closestAfterBooking.start.subtract(
1654
                                            useClosestFullyBookedForLead
1404
                                                1,
1655
                                                ? closestFullyBookedAfter.subtract(
1405
                                                "day"
1656
                                                      1,
1406
                                            );
1657
                                                      "day"
1658
                                                  )
1659
                                                : closestAfterBooking.start.subtract(
1660
                                                      1,
1661
                                                      "day"
1662
                                                  );
1407
1663
1408
                                        if (
1664
                                        if (
1409
                                            elemDate.isSameOrAfter(
1665
                                            elemDate.isSameOrAfter(
Lines 1475-1480 $("#placeBookingModal").on("show.bs.modal", function (e) { Link Here
1475
                                    }
1731
                                    }
1476
1732
1477
                                    // Check for conflicts with existing booking's trail period
1733
                                    // Check for conflicts with existing booking's trail period
1734
                                    // In "any item" mode, these classes now represent "all items booked" periods
1478
                                    if (
1735
                                    if (
1479
                                        !periodPicker.selectedDates[0] &&
1736
                                        !periodPicker.selectedDates[0] &&
1480
                                        dayElem.classList.contains(
1737
                                        dayElem.classList.contains(
Lines 1491-1496 $("#placeBookingModal").on("show.bs.modal", function (e) { Link Here
1491
                                    }
1748
                                    }
1492
1749
1493
                                    // Check for conflicts with existing booking's lead period
1750
                                    // Check for conflicts with existing booking's lead period
1751
                                    // In "any item" mode, these classes now represent "all items booked" periods
1494
                                    if (
1752
                                    if (
1495
                                        periodPicker.selectedDates[0] &&
1753
                                        periodPicker.selectedDates[0] &&
1496
                                        dayElem.classList.contains(
1754
                                        dayElem.classList.contains(
Lines 1518-1523 $("#placeBookingModal").on("show.bs.modal", function (e) { Link Here
1518
                                });
1776
                                });
1519
1777
1520
                            // Additional check for hovering directly on existing booking's lead/trail periods
1778
                            // Additional check for hovering directly on existing booking's lead/trail periods
1779
                            // In "any item" mode, these classes now represent "all items booked" periods
1521
                            // If hovering on an existing booking's lead period when selecting start date, block selection
1780
                            // If hovering on an existing booking's lead period when selecting start date, block selection
1522
                            if (
1781
                            if (
1523
                                !periodPicker.selectedDates[0] &&
1782
                                !periodPicker.selectedDates[0] &&
Lines 1527-1532 $("#placeBookingModal").on("show.bs.modal", function (e) { Link Here
1527
                            }
1786
                            }
1528
1787
1529
                            // If hovering on an existing booking's trail period when selecting end date, block selection
1788
                            // If hovering on an existing booking's trail period when selecting end date, block selection
1789
                            // In "any item" mode, these classes now represent "all items booked" periods
1530
                            if (
1790
                            if (
1531
                                periodPicker.selectedDates[0] &&
1791
                                periodPicker.selectedDates[0] &&
1532
                                target.classList.contains(
1792
                                target.classList.contains(
(-)a/t/cypress/integration/Circulation/bookingsModalBasic_spec.ts (-1 / +315 lines)
Lines 1485-1488 describe("Booking Modal Basic Tests", () => { Link Here
1485
            }
1485
            }
1486
        });
1486
        });
1487
    });
1487
    });
1488
1489
    it("should correctly handle lead/trail period conflicts for 'any item' bookings", () => {
1490
        /**
1491
         * Bug 37707: Lead/Trail Period Conflict Detection for "Any Item" Bookings
1492
         * ========================================================================
1493
         *
1494
         * This test validates that lead/trail period conflict detection works correctly
1495
         * when "any item of itemtype X" is selected. The key principle is:
1496
         *
1497
         * - Only block date selection when ALL items of the itemtype have conflicts
1498
         * - Allow selection when at least one item is free from lead/trail conflicts
1499
         *
1500
         * The bug occurred because the mouseover handler was checking conflicts against
1501
         * ALL bookings regardless of itemtype, rather than tracking per-item conflicts.
1502
         *
1503
         * Test Setup:
1504
         * ===========
1505
         * - 3 items of itemtype BK
1506
         * - Lead period: 2 days, Trail period: 2 days
1507
         * - ITEM 0: Booking on days 10-12 (trail period: 13-14)
1508
         * - ITEM 1: Booking on days 10-12 (same as item 0)
1509
         * - ITEM 2: No bookings (always available)
1510
         *
1511
         * Test Scenarios:
1512
         * ==============
1513
         * 1. Hover day 15: ITEM 0 and ITEM 1 have trail period conflict (lead period
1514
         *    June 13-14 overlaps their trail June 13-14), but ITEM 2 is free
1515
         *    → Should NOT be blocked (at least one item available)
1516
         *
1517
         * 2. Create booking on ITEM 2 for days 10-12, then hover day 15 again:
1518
         *    → ALL items now have trail period conflicts
1519
         *    → Should BE blocked
1520
         */
1521
1522
        const today = dayjs();
1523
        let testItems = [];
1524
        let testBiblio = null;
1525
        let testPatron = null;
1526
        let testLibraries = null;
1527
1528
        // Circulation rules with non-zero lead/trail periods
1529
        const circulationRules = {
1530
            bookings_lead_period: 2,
1531
            bookings_trail_period: 2,
1532
            issuelength: 14,
1533
            renewalsallowed: 2,
1534
            renewalperiod: 7,
1535
        };
1536
1537
        // Setup: Create biblio with 3 items of the same itemtype
1538
        cy.task("insertSampleBiblio", { item_count: 3 })
1539
            .then(objects => {
1540
                testBiblio = objects.biblio;
1541
                testItems = objects.items;
1542
                testLibraries = objects.libraries;
1543
1544
                // Make all items the same itemtype (BK)
1545
                const itemUpdates = testItems.map((item, index) => {
1546
                    const enumchron = String.fromCharCode(65 + index);
1547
                    return cy.task("query", {
1548
                        sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = ?, dateaccessioned = ? WHERE itemnumber = ?",
1549
                        values: [
1550
                            enumchron,
1551
                            `2024-12-0${4 - index}`,
1552
                            item.item_id,
1553
                        ],
1554
                    });
1555
                });
1556
                return Promise.all(itemUpdates);
1557
            })
1558
            .then(() => {
1559
                return cy.task("buildSampleObject", {
1560
                    object: "patron",
1561
                    values: {
1562
                        firstname: "LeadTrail",
1563
                        surname: "Tester",
1564
                        cardnumber: `LT${Date.now()}`,
1565
                        category_id: "PT",
1566
                        library_id: "CPL",
1567
                    },
1568
                });
1569
            })
1570
            .then(mockPatron => {
1571
                testPatron = mockPatron;
1572
                return cy.task("query", {
1573
                    sql: `INSERT INTO borrowers (borrowernumber, firstname, surname, cardnumber, categorycode, branchcode, dateofbirth)
1574
                          VALUES (?, ?, ?, ?, ?, ?, ?)`,
1575
                    values: [
1576
                        mockPatron.patron_id,
1577
                        mockPatron.firstname,
1578
                        mockPatron.surname,
1579
                        mockPatron.cardnumber,
1580
                        mockPatron.category_id,
1581
                        mockPatron.library_id,
1582
                        "1990-01-01",
1583
                    ],
1584
                });
1585
            })
1586
            .then(() => {
1587
                // Create bookings on ITEM 0 and ITEM 1 for days 10-12
1588
                // ITEM 2 remains free
1589
                const bookingInserts = [
1590
                    // ITEM 0: Booked days 10-12
1591
                    cy.task("query", {
1592
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1593
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1594
                        values: [
1595
                            testBiblio.biblio_id,
1596
                            testPatron.patron_id,
1597
                            testItems[0].item_id,
1598
                            "CPL",
1599
                            today.add(10, "day").format("YYYY-MM-DD"),
1600
                            today.add(12, "day").format("YYYY-MM-DD"),
1601
                            "new",
1602
                        ],
1603
                    }),
1604
                    // ITEM 1: Booked days 10-12 (same period)
1605
                    cy.task("query", {
1606
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1607
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1608
                        values: [
1609
                            testBiblio.biblio_id,
1610
                            testPatron.patron_id,
1611
                            testItems[1].item_id,
1612
                            "CPL",
1613
                            today.add(10, "day").format("YYYY-MM-DD"),
1614
                            today.add(12, "day").format("YYYY-MM-DD"),
1615
                            "new",
1616
                        ],
1617
                    }),
1618
                    // ITEM 2: No booking - remains free
1619
                ];
1620
                return Promise.all(bookingInserts);
1621
            })
1622
            .then(() => {
1623
                cy.intercept(
1624
                    "GET",
1625
                    `/api/v1/biblios/${testBiblio.biblio_id}/pickup_locations*`
1626
                ).as("getPickupLocations");
1627
                cy.intercept("GET", "/api/v1/circulation_rules*", {
1628
                    body: [circulationRules],
1629
                }).as("getCirculationRules");
1630
1631
                cy.visit(
1632
                    `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}`
1633
                );
1634
1635
                cy.get('[data-bs-target="#placeBookingModal"]').first().click();
1636
                cy.get("#placeBookingModal").should("be.visible");
1637
1638
                cy.selectFromSelect2(
1639
                    "#booking_patron_id",
1640
                    `${testPatron.surname}, ${testPatron.firstname}`,
1641
                    testPatron.cardnumber
1642
                );
1643
                cy.wait("@getPickupLocations");
1644
1645
                cy.get("#pickup_library_id").should("not.be.disabled");
1646
                cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
1647
1648
                // Select itemtype BK
1649
                cy.get("#booking_itemtype").should("not.be.disabled");
1650
                cy.selectFromSelect2("#booking_itemtype", "Books");
1651
                cy.wait("@getCirculationRules");
1652
1653
                // Select "Any item" (index 0)
1654
                cy.selectFromSelect2ByIndex("#booking_item_id", 0);
1655
                cy.get("#booking_item_id").should("have.value", "0");
1656
1657
                cy.get("#period").should("not.be.disabled");
1658
                cy.get("#period").as("flatpickrInput");
1659
1660
                // ================================================================
1661
                // SCENARIO 1: Hover day 15 - ITEM 2 is free, should NOT be blocked
1662
                // ================================================================
1663
                cy.log(
1664
                    "=== Scenario 1: Day 15 should be selectable (ITEM 2 is free) ==="
1665
                );
1666
1667
                /**
1668
                 * Day 15 as start date:
1669
                 * - Lead period: days 13-14
1670
                 * - ITEM 0's trail period: days 13-14 (booking ended day 12, trail = 2 days)
1671
                 * - ITEM 1's trail period: days 13-14 (same)
1672
                 * - ITEM 2: No booking, no trail period conflict
1673
                 *
1674
                 * The new booking's lead period (13-14) overlaps with ITEM 0 and ITEM 1's
1675
                 * trail period, but ITEM 2 has no conflict.
1676
                 *
1677
                 * With the bug, this would be blocked because ANY booking conflicted.
1678
                 * With the fix, this should be allowed because ITEM 2 is available.
1679
                 */
1680
1681
                cy.get("@flatpickrInput").openFlatpickr();
1682
                cy.get("@flatpickrInput")
1683
                    .getFlatpickrDate(today.add(15, "day").toDate())
1684
                    .trigger("mouseover");
1685
1686
                // Day 15 should NOT have leadDisable class (at least one item is free)
1687
                cy.get("@flatpickrInput")
1688
                    .getFlatpickrDate(today.add(15, "day").toDate())
1689
                    .should("not.have.class", "leadDisable");
1690
1691
                cy.log(
1692
                    "✓ Day 15 is selectable - lead period conflict detection correctly allows selection when one item is free"
1693
                );
1694
1695
                // Actually click day 15 to verify it's selectable
1696
                cy.get("@flatpickrInput")
1697
                    .getFlatpickrDate(today.add(15, "day").toDate())
1698
                    .should("not.have.class", "flatpickr-disabled")
1699
                    .click();
1700
1701
                // Verify day 15 was selected as start date
1702
                cy.get("@flatpickrInput")
1703
                    .getFlatpickrDate(today.add(15, "day").toDate())
1704
                    .should("have.class", "selected");
1705
1706
                cy.log(
1707
                    "✓ CONFIRMED: Day 15 successfully selected as start date"
1708
                );
1709
1710
                // Reset for next scenario
1711
                cy.get("@flatpickrInput").clearFlatpickr();
1712
1713
                // ================================================================
1714
                // SCENARIO 2: Add booking on ITEM 2 - ALL items now have conflicts
1715
                // ================================================================
1716
                cy.log(
1717
                    "=== Scenario 2: Day 15 should be BLOCKED when all items have conflicts ==="
1718
                );
1719
1720
                // Add booking on ITEM 2 for same period (days 10-12)
1721
                cy.task("query", {
1722
                    sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1723
                          VALUES (?, ?, ?, ?, ?, ?, ?)`,
1724
                    values: [
1725
                        testBiblio.biblio_id,
1726
                        testPatron.patron_id,
1727
                        testItems[2].item_id,
1728
                        "CPL",
1729
                        today.add(10, "day").format("YYYY-MM-DD"),
1730
                        today.add(12, "day").format("YYYY-MM-DD"),
1731
                        "new",
1732
                    ],
1733
                }).then(() => {
1734
                    // Reload page to get updated booking data
1735
                    cy.visit(
1736
                        `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}`
1737
                    );
1738
1739
                    cy.get('[data-bs-target="#placeBookingModal"]')
1740
                        .first()
1741
                        .click();
1742
                    cy.get("#placeBookingModal").should("be.visible");
1743
1744
                    cy.selectFromSelect2(
1745
                        "#booking_patron_id",
1746
                        `${testPatron.surname}, ${testPatron.firstname}`,
1747
                        testPatron.cardnumber
1748
                    );
1749
                    cy.wait("@getPickupLocations");
1750
1751
                    cy.get("#pickup_library_id").should("not.be.disabled");
1752
                    cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
1753
1754
                    // Select itemtype BK
1755
                    cy.get("#booking_itemtype").should("not.be.disabled");
1756
                    cy.selectFromSelect2("#booking_itemtype", "Books");
1757
                    cy.wait("@getCirculationRules");
1758
1759
                    // Select "Any item" (index 0)
1760
                    cy.selectFromSelect2ByIndex("#booking_item_id", 0);
1761
                    cy.get("#booking_item_id").should("have.value", "0");
1762
1763
                    cy.get("#period").should("not.be.disabled");
1764
                    cy.get("#period").as("flatpickrInput2");
1765
1766
                    cy.get("@flatpickrInput2").openFlatpickr();
1767
                    cy.get("@flatpickrInput2")
1768
                        .getFlatpickrDate(today.add(15, "day").toDate())
1769
                        .trigger("mouseover");
1770
1771
                    // Day 15 should NOW have leadDisable class (all items have conflicts)
1772
                    cy.get("@flatpickrInput2")
1773
                        .getFlatpickrDate(today.add(15, "day").toDate())
1774
                        .should("have.class", "leadDisable");
1775
1776
                    cy.log(
1777
                        "✓ Day 15 is BLOCKED - all items have lead period conflicts"
1778
                    );
1779
                });
1780
            });
1781
1782
        // Cleanup
1783
        cy.then(() => {
1784
            if (testBiblio) {
1785
                cy.task("query", {
1786
                    sql: "DELETE FROM bookings WHERE biblio_id = ?",
1787
                    values: [testBiblio.biblio_id],
1788
                });
1789
                cy.task("deleteSampleObjects", {
1790
                    biblio: testBiblio,
1791
                    items: testItems,
1792
                    libraries: testLibraries,
1793
                });
1794
            }
1795
            if (testPatron) {
1796
                cy.task("query", {
1797
                    sql: "DELETE FROM borrowers WHERE borrowernumber = ?",
1798
                    values: [testPatron.patron_id],
1799
                });
1800
            }
1801
        });
1802
    });
1488
});
1803
});
1489
- 

Return to bug 37707