完成报表中心

This commit is contained in:
Zzzz
2026-04-27 22:07:20 +08:00
parent d503036aeb
commit f9a9c630c2
26 changed files with 1127 additions and 4 deletions
+5
View File
@@ -13,3 +13,8 @@ project_doc_fallback_filenames = ["AGENTS.md"]
#
# Without this flag, hooks.json is ignored and Trellis context won't
# be injected into Codex sessions.
sandbox_mode = "workspace-write"
[sandbox_workspace_write]
network_access = true
+89 -3
View File
@@ -29,9 +29,6 @@ Implemented scaffold tables:
status.
- `readers`: reader profiles, optional login-account linkage, borrowing
eligibility, contact information, and management status.
Planned module tables:
- `borrow_records`: book-reader borrowing, return, renew, and overdue data.
Record new schema changes in `src/main/resources/db/schema.sql` and update this
@@ -427,6 +424,95 @@ borrowing/manage.jsp -> JDBC -> UPDATE books SET available_copies = ...
borrowing/form.jsp -> BorrowingManagementServlet -> BorrowingService -> BorrowRecordDao -> borrow_records + books in one transaction
```
## Scenario: Report Center Slice
### 1. Scope / Trigger
- Trigger: the report center adds staff-only operational reporting across
`books`, `readers`, and `borrow_records` without adding report tables.
- Schema path: `src/main/resources/db/schema.sql`.
- JSP path: `WEB-INF/jsp/reports/dashboard.jsp`.
### 2. Signatures
- Entity signatures:
- `InventorySummary(totalTitles, totalCopies, availableCopies,
unavailableOrEmptyTitles)`.
- `BorrowingSummary(activeLoans, returnedLoans, overdueLoans)`.
- `OverdueReportRow(readerIdentifier, readerName, bookIdentifier,
bookTitle, dueAt, overdueDays)`.
- `PopularBookReportRow(bookIdentifier, title, author, borrowCount)`.
- `ReportCenter(inventorySummary, borrowingSummary, overdueRows,
popularBooks)`.
- DAO signatures: `ReportDao.loadInventorySummary()`,
`loadBorrowingSummary()`, `findOverdueRows()`, and
`findPopularBooks(int limit)`.
- Service signature: `ReportService.loadReportCenter(AuthenticatedUser actor)`
returning `ServiceResult<ReportCenter>`.
- Servlet route: `GET /reports`.
- Protected permission: `/reports` requires `VIEW_REPORTS`.
### 3. Contracts
- Report data is read-only and derived from existing `books`, `readers`, and
`borrow_records` rows; do not introduce aggregate/cache tables for the MVP.
- `unavailableOrEmptyTitles` counts book rows where `books.status` is not
`available` or `books.available_copies <= 0`.
- `activeLoans` counts active borrow rows where `returned_at IS NULL`.
- `returnedLoans` counts rows with `status = returned`.
- `overdueLoans` and overdue rows use the derived rule
`status = active AND returned_at IS NULL AND due_at < CURRENT_TIMESTAMP`.
- Popular book ranking groups by book and orders by borrow record count
descending, with a service/DAO limit for the top rows.
- `ReportServlet` sets the `reportCenter` request attribute on success and
`errorMessage` on safe service failure.
- JSP pages render JavaBean properties only; they must not call DAOs or embed
SQL.
- Dashboard, role-home, and header navigation should expose reports only to
administrator/librarian users.
### 4. Validation & Error Matrix
- Missing or unauthenticated actor -> `You do not have permission to view reports.`
- Reader actor -> `You do not have permission to view reports.`
- DAO failure while loading any report section -> log server-side details and
return `Report service is temporarily unavailable. Please try again later.`
- Empty overdue list -> render a stable empty state, not an error.
- Empty popular ranking -> render a stable empty state, not an error.
### 5. Good/Base/Bad Cases
- Good: a librarian opens `/reports` and sees inventory totals, borrowing
counts, active overdue rows, and top borrowed books.
- Base: no borrowing records exist; summaries show zero counts and tables show
empty states.
- Bad: a reader reaches `/reports`, a JSP performs `SELECT` queries directly,
or report queries update inventory/borrow state.
### 6. Tests Required
- Run `ReportServiceCheck` assertions for reader denial, librarian success,
report section composition, and DAO failure fallback.
- Run `PermissionPolicyCheck` to confirm administrator/librarian roles allow
`VIEW_REPORTS` and readers do not.
- Scan report JSPs for scriptlets and SQL/JDBC references.
- When Maven/Tomcat dependencies are installed, run `mvn clean package` to
compile Servlets and package JSP resources.
### 7. Wrong vs Correct
#### Wrong
```text
reports/dashboard.jsp -> JDBC -> SELECT COUNT(*) FROM borrow_records
```
#### Correct
```text
reports/dashboard.jsp <- ReportServlet <- ReportService <- ReportDao <- books/readers/borrow_records
```
## Scenario: Login And Permission Scaffold Schema
### 1. Scope / Trigger
@@ -0,0 +1,12 @@
{"file": ".trellis/spec/backend/index.md", "reason": "Backend architecture and checklist for reviewing Servlet-Service-DAO report changes."}
{"file": ".trellis/spec/backend/directory-structure.md", "reason": "Verify report classes are placed consistently with backend structure."}
{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Review JDBC aggregate queries and DAO boundaries for report data."}
{"file": ".trellis/spec/backend/error-handling.md", "reason": "Verify servlet/service validation and failure behavior."}
{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Check maintainability, focused tests, and layer boundaries."}
{"file": ".trellis/spec/frontend/index.md", "reason": "Review JSP report page against frontend conventions."}
{"file": ".trellis/spec/frontend/directory-structure.md", "reason": "Verify JSP placement and shared layout usage."}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Check dashboard cards, tables, and report UI consistency."}
{"file": ".trellis/spec/frontend/state-management.md", "reason": "Verify servlet-to-JSP request state is explicit and null-safe."}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Check UI consistency and responsive/server-rendered page quality."}
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "Reports span DAO, service, servlet, JSP, and permissions."}
{"file": ".trellis/tasks/archive/2026-04/00-bootstrap-guidelines/research/project-requirements.md", "reason": "Original project requirements include reports and statistics expectations."}
@@ -0,0 +1,14 @@
{"file": ".trellis/spec/backend/index.md", "reason": "Backend architecture and pre-development checklist for Servlet-Service-DAO work."}
{"file": ".trellis/spec/backend/directory-structure.md", "reason": "Place report controller, service, DAO, and entities consistently."}
{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Use MySQL/JDBC aggregate queries and DAO boundaries correctly."}
{"file": ".trellis/spec/backend/error-handling.md", "reason": "Follow existing servlet/service validation and safe failure patterns."}
{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "Consider reporting and maintenance logging conventions without over-scoping."}
{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Maintain layer boundaries and focused verification."}
{"file": ".trellis/spec/frontend/index.md", "reason": "JSP/CSS presentation conventions for server-rendered report pages."}
{"file": ".trellis/spec/frontend/directory-structure.md", "reason": "Place report JSPs and shared includes consistently."}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Reuse page, card, form, and table UI patterns."}
{"file": ".trellis/spec/frontend/state-management.md", "reason": "Use request/session state correctly for server-rendered reports."}
{"file": ".trellis/spec/frontend/type-safety.md", "reason": "Keep servlet-to-JSP display contracts explicit and null-safe."}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Keep the report UI consistent with existing JSP pages."}
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "Reports span DAO, service, servlet, JSP, and permissions."}
{"file": ".trellis/tasks/archive/2026-04/00-bootstrap-guidelines/research/project-requirements.md", "reason": "Original project requirements include reports and statistics expectations."}
@@ -0,0 +1,82 @@
# 按顺序继续完成程序
## Goal
继续完成 MZH Library Management 的下一组核心功能,让现有 JSP + Servlet + JDBC 图书馆系统从已完成的基础业务链路继续向完整后台能力推进。
## What I already know
* 用户要求“按顺序继续完成程序”,当前没有 active task,因此本任务用于恢复开发方向并确认下一步范围。
* 用户已选择下一步做 **报表中心**
* 项目是 Java 11 Maven WAR,使用 JSP + Servlet + MySQL/JDBC。
* 已有认证、权限过滤、角色首页、图书目录、图书管理、读者管理、借书/还书/续借/逾期、读者借阅历史。
* 最近提交顺序显示功能推进为:认证/权限 -> 图书 -> 读者 -> 借还续借逾期。
* 数据库和权限模型中还预留了 `manage_users``view_reports``view_system_logs` 等能力。
* 当前后台 Administration 卡片文案提到账号、角色、权限、系统维护入口,但实际还没有独立用户管理和系统日志页面。
## Assumptions (temporary)
* “按顺序”优先遵循现有业务链和权限模型:借阅链路完成后,应补齐统计报表或管理后台缺口。
* 本轮应选择一个清晰、可验收的 MVP 模块,不把用户管理、报表、日志全部混在一次任务里。
## Open Questions
* None for this MVP.
## Requirements
* 继续沿用现有 Servlet -> Service -> DAO -> JSP 分层。
* 新增报表中心,面向拥有 `view_reports` 权限的管理员和馆员。
* 报表中心应至少展示:
* 图书库存概览:总书目数、总册数、可借册数、不可借/归档或无可借库存的提示统计。
* 借阅概览:当前借出、已归还、已逾期数量。
* 逾期清单:展示读者、图书、应还日期、逾期天数等关键字段。
* 热门借阅排行:按借阅记录数量展示 Top 图书。
* 新页面入口应出现在 dashboard / role-home 的管理员和馆员工作区中。
* 新功能必须接入现有角色权限体系;读者不能访问报表中心。
* 新功能应包含 focused service checks,维持当前项目的轻量测试风格。
* 报表数据应基于现有 `books``readers``borrow_records` 表,不引入新表。
## Acceptance Criteria
* [x] 明确下一步 MVP 模块:报表中心。
* [x] 新模块有受权限保护的入口和页面。
* [x] 新模块使用现有分层和错误处理风格。
* [x] 管理员和馆员可打开报表中心,读者访问会被拒绝。
* [x] 报表页面展示库存概览、借阅概览、逾期清单、热门借阅排行。
* [x] 新增或更新服务层检查。
* [ ] Maven 构建通过(当前环境未安装 `mvn`,已用 `javac` fallback 和轻量 checks 验证)。
## Definition of Done (team quality bar)
* Tests added/updated where appropriate.
* Maven compile/package checks pass where available.
* Docs/notes updated if behavior changes.
* Rollout/rollback considered if risky.
## Out of Scope (explicit)
* 暂不一次性实现用户管理和系统日志页面。
* 暂不实现图表库、导出 Excel/PDF、按日期筛选等高级报表能力。
* 暂不引入新框架或前后端分离。
* 暂不修改现有认证和核心借阅规则,除非所选模块必须依赖。
## Technical Approach
Add a reports slice consistent with the existing architecture: entity/value objects for report summaries, DAO queries for aggregate data, a service facade for report composition and permission checks, a servlet mapped to `/reports`, and a JSP under `WEB-INF/jsp/reports/`. Reuse existing CSS patterns for compact dashboard cards and tables.
## Decision (ADR-lite)
**Context**: The borrowing workflow is now implemented, and the permission model already contains `view_reports`.
**Decision**: Implement a server-rendered report center as the next MVP module, focused on operational summaries that can be derived from existing tables.
**Consequences**: This gives administrators and librarians immediate visibility into inventory and borrowing health without introducing reporting infrastructure. Date filters, exports, and charts remain future enhancements.
## Technical Notes
* `README.md` 描述当前项目结构和本地部署方式。
* `src/main/resources/db/schema.sql` 已有 `system_logs` 表、`view_reports``view_system_logs` 权限。
* `src/main/webapp/WEB-INF/web.xml` 当前未映射 reports、logs 或 users 管理 servlet。
* `src/main/webapp/WEB-INF/jsp/dashboard.jsp``role-home.jsp` 是新增入口的主要位置。
* 语义代码检索 MCP 返回 403 权限错误;本轮自动上下文改用本地只读文件检查。
@@ -0,0 +1,26 @@
{
"id": "continue-program-sequence",
"name": "continue-program-sequence",
"title": "brainstorm: 按顺序继续完成程序",
"description": "",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "Zzzz",
"assignee": "Zzzz",
"createdAt": "2026-04-27",
"completedAt": null,
"branch": null,
"base_branch": "master",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+1 -1
View File
@@ -22,4 +22,4 @@ mvn clean package
5. Deploy `target/library-management.war` to Tomcat.
The implemented scaffold slices now cover login/permission checks, catalog and book management, and reader profile management. Authentication stores only a safe authenticated-user snapshot in the HTTP session, and business workflows stay in Servlet -> Service -> DAO boundaries.
The implemented scaffold slices now cover login/permission checks, catalog and book management, reader profile management, borrowing circulation, reader loan history, and the staff report center. Authentication stores only a safe authenticated-user snapshot in the HTTP session, and business workflows stay in Servlet -> Service -> DAO boundaries.
@@ -0,0 +1,64 @@
package com.mzh.library.controller;
import com.mzh.library.dao.impl.JdbcReportDao;
import com.mzh.library.entity.AuthenticatedUser;
import com.mzh.library.entity.ReportCenter;
import com.mzh.library.service.ReportService;
import com.mzh.library.service.ServiceResult;
import com.mzh.library.service.impl.ReportServiceImpl;
import com.mzh.library.util.SessionAttributes;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
public class ReportServlet extends HttpServlet {
private static final String REPORT_JSP = "/WEB-INF/jsp/reports/dashboard.jsp";
private static final String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp";
private ReportService reportService;
@Override
public void init() {
this.reportService = new ReportServiceImpl(new JdbcReportDao());
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
ServiceResult<ReportCenter> result = reportService.loadReportCenter(currentUser(request));
if (isPermissionDenied(result)) {
forwardDenied(request, response, result.getMessage());
return;
}
if (result.isSuccessful()) {
request.setAttribute("reportCenter", result.getData());
} else {
request.setAttribute("errorMessage", result.getMessage());
}
request.getRequestDispatcher(REPORT_JSP).forward(request, response);
}
private boolean isPermissionDenied(ServiceResult<?> result) {
return !result.isSuccessful()
&& "You do not have permission to view reports.".equals(result.getMessage());
}
private void forwardDenied(HttpServletRequest request, HttpServletResponse response, String message)
throws ServletException, IOException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
request.setAttribute("errorMessage", message);
request.getRequestDispatcher(UNAUTHORIZED_JSP).forward(request, response);
}
private AuthenticatedUser currentUser(HttpServletRequest request) {
HttpSession session = request.getSession(false);
Object value = session == null ? null : session.getAttribute(SessionAttributes.AUTHENTICATED_USER);
return value instanceof AuthenticatedUser ? (AuthenticatedUser) value : null;
}
}
@@ -0,0 +1,18 @@
package com.mzh.library.dao;
import com.mzh.library.entity.BorrowingSummary;
import com.mzh.library.entity.InventorySummary;
import com.mzh.library.entity.OverdueReportRow;
import com.mzh.library.entity.PopularBookReportRow;
import java.util.List;
public interface ReportDao {
InventorySummary loadInventorySummary();
BorrowingSummary loadBorrowingSummary();
List<OverdueReportRow> findOverdueRows();
List<PopularBookReportRow> findPopularBooks(int limit);
}
@@ -0,0 +1,157 @@
package com.mzh.library.dao.impl;
import com.mzh.library.dao.ReportDao;
import com.mzh.library.entity.BookStatus;
import com.mzh.library.entity.BorrowRecordStatus;
import com.mzh.library.entity.BorrowingSummary;
import com.mzh.library.entity.InventorySummary;
import com.mzh.library.entity.OverdueReportRow;
import com.mzh.library.entity.PopularBookReportRow;
import com.mzh.library.exception.DaoException;
import com.mzh.library.util.JdbcUtil;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
public class JdbcReportDao implements ReportDao {
private static final String INVENTORY_SUMMARY = ""
+ "SELECT COUNT(*) AS total_titles, "
+ "COALESCE(SUM(total_copies), 0) AS total_copies, "
+ "COALESCE(SUM(available_copies), 0) AS available_copies, "
+ "COALESCE(SUM(CASE WHEN status <> ? OR available_copies <= 0 THEN 1 ELSE 0 END), 0) "
+ "AS unavailable_or_empty_titles "
+ "FROM books";
private static final String BORROWING_SUMMARY = ""
+ "SELECT "
+ "COALESCE(SUM(CASE WHEN status = ? AND returned_at IS NULL THEN 1 ELSE 0 END), 0) AS active_loans, "
+ "COALESCE(SUM(CASE WHEN status = ? THEN 1 ELSE 0 END), 0) AS returned_loans, "
+ "COALESCE(SUM(CASE WHEN status = ? AND returned_at IS NULL AND due_at < CURRENT_TIMESTAMP "
+ "THEN 1 ELSE 0 END), 0) AS overdue_loans "
+ "FROM borrow_records";
private static final String OVERDUE_ROWS = ""
+ "SELECT r.reader_identifier, r.full_name AS reader_name, "
+ "b.book_identifier, b.title AS book_title, br.due_at, "
+ "GREATEST(DATEDIFF(CURRENT_DATE, DATE(br.due_at)), 0) AS overdue_days "
+ "FROM borrow_records br "
+ "JOIN readers r ON r.id = br.reader_id "
+ "JOIN books b ON b.id = br.book_id "
+ "WHERE br.status = ? AND br.returned_at IS NULL AND br.due_at < CURRENT_TIMESTAMP "
+ "ORDER BY br.due_at, r.reader_identifier, b.book_identifier";
private static final String POPULAR_BOOKS = ""
+ "SELECT b.book_identifier, b.title, b.author, COUNT(br.id) AS borrow_count "
+ "FROM books b "
+ "JOIN borrow_records br ON br.book_id = b.id "
+ "GROUP BY b.id, b.book_identifier, b.title, b.author "
+ "ORDER BY borrow_count DESC, b.title, b.book_identifier "
+ "LIMIT ?";
@Override
public InventorySummary loadInventorySummary() {
try (Connection connection = JdbcUtil.getConnection();
PreparedStatement statement = connection.prepareStatement(INVENTORY_SUMMARY)) {
statement.setString(1, BookStatus.AVAILABLE.getCode());
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
InventorySummary summary = new InventorySummary();
summary.setTotalTitles(resultSet.getInt("total_titles"));
summary.setTotalCopies(resultSet.getInt("total_copies"));
summary.setAvailableCopies(resultSet.getInt("available_copies"));
summary.setUnavailableOrEmptyTitles(resultSet.getInt("unavailable_or_empty_titles"));
return summary;
}
return new InventorySummary();
}
} catch (SQLException ex) {
throw new DaoException("Unable to load inventory report summary", ex);
}
}
@Override
public BorrowingSummary loadBorrowingSummary() {
try (Connection connection = JdbcUtil.getConnection();
PreparedStatement statement = connection.prepareStatement(BORROWING_SUMMARY)) {
statement.setString(1, BorrowRecordStatus.ACTIVE.getCode());
statement.setString(2, BorrowRecordStatus.RETURNED.getCode());
statement.setString(3, BorrowRecordStatus.ACTIVE.getCode());
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
BorrowingSummary summary = new BorrowingSummary();
summary.setActiveLoans(resultSet.getInt("active_loans"));
summary.setReturnedLoans(resultSet.getInt("returned_loans"));
summary.setOverdueLoans(resultSet.getInt("overdue_loans"));
return summary;
}
return new BorrowingSummary();
}
} catch (SQLException ex) {
throw new DaoException("Unable to load borrowing report summary", ex);
}
}
@Override
public List<OverdueReportRow> findOverdueRows() {
try (Connection connection = JdbcUtil.getConnection();
PreparedStatement statement = connection.prepareStatement(OVERDUE_ROWS)) {
statement.setString(1, BorrowRecordStatus.ACTIVE.getCode());
try (ResultSet resultSet = statement.executeQuery()) {
List<OverdueReportRow> rows = new ArrayList<>();
while (resultSet.next()) {
rows.add(mapOverdueRow(resultSet));
}
return rows;
}
} catch (SQLException ex) {
throw new DaoException("Unable to load overdue report rows", ex);
}
}
@Override
public List<PopularBookReportRow> findPopularBooks(int limit) {
try (Connection connection = JdbcUtil.getConnection();
PreparedStatement statement = connection.prepareStatement(POPULAR_BOOKS)) {
statement.setInt(1, limit);
try (ResultSet resultSet = statement.executeQuery()) {
List<PopularBookReportRow> rows = new ArrayList<>();
while (resultSet.next()) {
rows.add(mapPopularBook(resultSet));
}
return rows;
}
} catch (SQLException ex) {
throw new DaoException("Unable to load popular book report rows", ex);
}
}
private OverdueReportRow mapOverdueRow(ResultSet resultSet) throws SQLException {
OverdueReportRow row = new OverdueReportRow();
row.setReaderIdentifier(resultSet.getString("reader_identifier"));
row.setReaderName(resultSet.getString("reader_name"));
row.setBookIdentifier(resultSet.getString("book_identifier"));
row.setBookTitle(resultSet.getString("book_title"));
row.setDueAt(toLocalDateTime(resultSet.getTimestamp("due_at")));
row.setOverdueDays(resultSet.getLong("overdue_days"));
return row;
}
private PopularBookReportRow mapPopularBook(ResultSet resultSet) throws SQLException {
PopularBookReportRow row = new PopularBookReportRow();
row.setBookIdentifier(resultSet.getString("book_identifier"));
row.setTitle(resultSet.getString("title"));
row.setAuthor(resultSet.getString("author"));
row.setBorrowCount(resultSet.getInt("borrow_count"));
return row;
}
private LocalDateTime toLocalDateTime(Timestamp timestamp) {
return timestamp == null ? null : timestamp.toLocalDateTime();
}
}
@@ -0,0 +1,31 @@
package com.mzh.library.entity;
public class BorrowingSummary {
private int activeLoans;
private int returnedLoans;
private int overdueLoans;
public int getActiveLoans() {
return activeLoans;
}
public void setActiveLoans(int activeLoans) {
this.activeLoans = activeLoans;
}
public int getReturnedLoans() {
return returnedLoans;
}
public void setReturnedLoans(int returnedLoans) {
this.returnedLoans = returnedLoans;
}
public int getOverdueLoans() {
return overdueLoans;
}
public void setOverdueLoans(int overdueLoans) {
this.overdueLoans = overdueLoans;
}
}
@@ -0,0 +1,40 @@
package com.mzh.library.entity;
public class InventorySummary {
private int totalTitles;
private int totalCopies;
private int availableCopies;
private int unavailableOrEmptyTitles;
public int getTotalTitles() {
return totalTitles;
}
public void setTotalTitles(int totalTitles) {
this.totalTitles = totalTitles;
}
public int getTotalCopies() {
return totalCopies;
}
public void setTotalCopies(int totalCopies) {
this.totalCopies = totalCopies;
}
public int getAvailableCopies() {
return availableCopies;
}
public void setAvailableCopies(int availableCopies) {
this.availableCopies = availableCopies;
}
public int getUnavailableOrEmptyTitles() {
return unavailableOrEmptyTitles;
}
public void setUnavailableOrEmptyTitles(int unavailableOrEmptyTitles) {
this.unavailableOrEmptyTitles = unavailableOrEmptyTitles;
}
}
@@ -0,0 +1,67 @@
package com.mzh.library.entity;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class OverdueReportRow {
private static final DateTimeFormatter DISPLAY_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private String readerIdentifier;
private String readerName;
private String bookIdentifier;
private String bookTitle;
private LocalDateTime dueAt;
private long overdueDays;
public String getReaderIdentifier() {
return readerIdentifier;
}
public void setReaderIdentifier(String readerIdentifier) {
this.readerIdentifier = readerIdentifier;
}
public String getReaderName() {
return readerName;
}
public void setReaderName(String readerName) {
this.readerName = readerName;
}
public String getBookIdentifier() {
return bookIdentifier;
}
public void setBookIdentifier(String bookIdentifier) {
this.bookIdentifier = bookIdentifier;
}
public String getBookTitle() {
return bookTitle;
}
public void setBookTitle(String bookTitle) {
this.bookTitle = bookTitle;
}
public LocalDateTime getDueAt() {
return dueAt;
}
public void setDueAt(LocalDateTime dueAt) {
this.dueAt = dueAt;
}
public long getOverdueDays() {
return overdueDays;
}
public void setOverdueDays(long overdueDays) {
this.overdueDays = overdueDays;
}
public String getDueAtText() {
return dueAt == null ? "" : DISPLAY_FORMAT.format(dueAt);
}
}
@@ -0,0 +1,40 @@
package com.mzh.library.entity;
public class PopularBookReportRow {
private String bookIdentifier;
private String title;
private String author;
private int borrowCount;
public String getBookIdentifier() {
return bookIdentifier;
}
public void setBookIdentifier(String bookIdentifier) {
this.bookIdentifier = bookIdentifier;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public int getBorrowCount() {
return borrowCount;
}
public void setBorrowCount(int borrowCount) {
this.borrowCount = borrowCount;
}
}
@@ -0,0 +1,51 @@
package com.mzh.library.entity;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class ReportCenter {
private InventorySummary inventorySummary;
private BorrowingSummary borrowingSummary;
private List<OverdueReportRow> overdueRows = Collections.emptyList();
private List<PopularBookReportRow> popularBooks = Collections.emptyList();
public InventorySummary getInventorySummary() {
return inventorySummary;
}
public void setInventorySummary(InventorySummary inventorySummary) {
this.inventorySummary = inventorySummary;
}
public BorrowingSummary getBorrowingSummary() {
return borrowingSummary;
}
public void setBorrowingSummary(BorrowingSummary borrowingSummary) {
this.borrowingSummary = borrowingSummary;
}
public List<OverdueReportRow> getOverdueRows() {
return overdueRows;
}
public void setOverdueRows(List<OverdueReportRow> overdueRows) {
this.overdueRows = immutableCopy(overdueRows);
}
public List<PopularBookReportRow> getPopularBooks() {
return popularBooks;
}
public void setPopularBooks(List<PopularBookReportRow> popularBooks) {
this.popularBooks = immutableCopy(popularBooks);
}
private static <T> List<T> immutableCopy(List<T> source) {
if (source == null || source.isEmpty()) {
return Collections.emptyList();
}
return Collections.unmodifiableList(new ArrayList<>(source));
}
}
@@ -26,6 +26,7 @@ public class AuthorizationFilter implements Filter {
private static final Logger LOGGER = Logger.getLogger(AuthorizationFilter.class.getName());
private static final String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp";
private static final List<PathRule> RULES = Arrays.asList(
new PathRule("/reports", Permission.VIEW_REPORTS),
new PathRule("/borrowing", Permission.MANAGE_BORROWING),
new PathRule("/books", Permission.MANAGE_BOOKS),
new PathRule("/readers", Permission.MANAGE_READERS),
@@ -0,0 +1,8 @@
package com.mzh.library.service;
import com.mzh.library.entity.AuthenticatedUser;
import com.mzh.library.entity.ReportCenter;
public interface ReportService {
ServiceResult<ReportCenter> loadReportCenter(AuthenticatedUser actor);
}
@@ -0,0 +1,56 @@
package com.mzh.library.service.impl;
import com.mzh.library.dao.ReportDao;
import com.mzh.library.entity.AuthenticatedUser;
import com.mzh.library.entity.Permission;
import com.mzh.library.entity.ReportCenter;
import com.mzh.library.exception.DaoException;
import com.mzh.library.service.PermissionPolicy;
import com.mzh.library.service.ReportService;
import com.mzh.library.service.ServiceResult;
import java.util.logging.Level;
import java.util.logging.Logger;
public class ReportServiceImpl implements ReportService {
private static final Logger LOGGER = Logger.getLogger(ReportServiceImpl.class.getName());
private static final String UNAVAILABLE_MESSAGE =
"Report service is temporarily unavailable. Please try again later.";
private static final String DENIED_MESSAGE = "You do not have permission to view reports.";
private static final int POPULAR_BOOK_LIMIT = 10;
private final ReportDao reportDao;
private final PermissionPolicy permissionPolicy;
public ReportServiceImpl(ReportDao reportDao) {
this(reportDao, new PermissionPolicy());
}
public ReportServiceImpl(ReportDao reportDao, PermissionPolicy permissionPolicy) {
this.reportDao = reportDao;
this.permissionPolicy = permissionPolicy;
}
@Override
public ServiceResult<ReportCenter> loadReportCenter(AuthenticatedUser actor) {
if (!canViewReports(actor)) {
return ServiceResult.failure(DENIED_MESSAGE);
}
try {
ReportCenter reportCenter = new ReportCenter();
reportCenter.setInventorySummary(reportDao.loadInventorySummary());
reportCenter.setBorrowingSummary(reportDao.loadBorrowingSummary());
reportCenter.setOverdueRows(reportDao.findOverdueRows());
reportCenter.setPopularBooks(reportDao.findPopularBooks(POPULAR_BOOK_LIMIT));
return ServiceResult.success(reportCenter);
} catch (DaoException ex) {
LOGGER.log(Level.SEVERE, "Unable to load report center actorId=" + actor.getId(), ex);
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
}
}
private boolean canViewReports(AuthenticatedUser actor) {
return actor != null && permissionPolicy.allows(actor.getRole(), Permission.VIEW_REPORTS);
}
}
@@ -13,6 +13,7 @@
<a href="${pageContext.request.contextPath}/books">Books</a>
<a href="${pageContext.request.contextPath}/readers">Readers</a>
<a href="${pageContext.request.contextPath}/borrowing">Borrowing</a>
<a href="${pageContext.request.contextPath}/reports">Reports</a>
</c:if>
<a href="${pageContext.request.contextPath}/reader/home">Reader</a>
<c:if test="${sessionScope.userRole == 'reader'}">
@@ -52,6 +52,12 @@
<p>Create loans, process returns, renew active records, and review overdue items.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/borrowing">Open</a>
</article>
<article class="workspace-card">
<h2>Report Center</h2>
<p>Review inventory health, borrowing counts, overdue records, and popular books.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/reports">Open</a>
</article>
</c:if>
<article class="workspace-card">
@@ -0,0 +1,147 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reports - MZH Library</title>
<link rel="stylesheet" href="${pageContext.request.contextPath}/static/css/app.css">
</head>
<body>
<%@ include file="/WEB-INF/jsp/common/header.jspf" %>
<main class="page-shell">
<section class="dashboard-hero catalog-hero" aria-labelledby="reports-title">
<div>
<p class="eyebrow">Reports</p>
<h1 id="reports-title">Report center</h1>
<p>Review collection inventory, borrowing health, overdue loans, and popular books.</p>
</div>
<a class="button button-secondary" href="${pageContext.request.contextPath}/borrowing">Borrowing records</a>
</section>
<c:if test="${not empty errorMessage}">
<div class="message message-error" role="alert">
<c:out value="${errorMessage}" />
</div>
</c:if>
<c:if test="${not empty reportCenter}">
<section class="report-grid" aria-label="Report summary">
<article class="report-card">
<p class="eyebrow">Inventory</p>
<h2>Total titles</h2>
<p class="report-metric"><c:out value="${reportCenter.inventorySummary.totalTitles}" /></p>
</article>
<article class="report-card">
<p class="eyebrow">Inventory</p>
<h2>Total copies</h2>
<p class="report-metric"><c:out value="${reportCenter.inventorySummary.totalCopies}" /></p>
</article>
<article class="report-card">
<p class="eyebrow">Inventory</p>
<h2>Available copies</h2>
<p class="report-metric"><c:out value="${reportCenter.inventorySummary.availableCopies}" /></p>
</article>
<article class="report-card">
<p class="eyebrow">Attention</p>
<h2>Unavailable or empty</h2>
<p class="report-metric"><c:out value="${reportCenter.inventorySummary.unavailableOrEmptyTitles}" /></p>
</article>
<article class="report-card">
<p class="eyebrow">Borrowing</p>
<h2>Currently borrowed</h2>
<p class="report-metric"><c:out value="${reportCenter.borrowingSummary.activeLoans}" /></p>
</article>
<article class="report-card">
<p class="eyebrow">Borrowing</p>
<h2>Returned records</h2>
<p class="report-metric"><c:out value="${reportCenter.borrowingSummary.returnedLoans}" /></p>
</article>
<article class="report-card report-card-alert">
<p class="eyebrow">Borrowing</p>
<h2>Overdue loans</h2>
<p class="report-metric"><c:out value="${reportCenter.borrowingSummary.overdueLoans}" /></p>
</article>
</section>
<section class="table-panel" aria-labelledby="overdue-report-title">
<h2 id="overdue-report-title">Overdue list</h2>
<c:choose>
<c:when test="${empty reportCenter.overdueRows}">
<p class="empty-state">No active overdue borrowing records.</p>
</c:when>
<c:otherwise>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th scope="col">Reader</th>
<th scope="col">Book</th>
<th scope="col">Due date</th>
<th scope="col">Overdue days</th>
</tr>
</thead>
<tbody>
<c:forEach var="row" items="${reportCenter.overdueRows}">
<tr>
<td>
<strong><c:out value="${row.readerIdentifier}" /></strong>
<div class="muted-text"><c:out value="${row.readerName}" /></div>
</td>
<td>
<strong><c:out value="${row.bookIdentifier}" /></strong>
<div class="muted-text"><c:out value="${row.bookTitle}" /></div>
</td>
<td><c:out value="${row.dueAtText}" /></td>
<td>
<span class="status-pill status-overdue">
<c:out value="${row.overdueDays}" /> days
</span>
</td>
</tr>
</c:forEach>
</tbody>
</table>
</div>
</c:otherwise>
</c:choose>
</section>
<section class="table-panel" aria-labelledby="popular-report-title">
<h2 id="popular-report-title">Popular borrowing ranking</h2>
<c:choose>
<c:when test="${empty reportCenter.popularBooks}">
<p class="empty-state">No borrowing records are available for ranking yet.</p>
</c:when>
<c:otherwise>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th scope="col">Book</th>
<th scope="col">Author</th>
<th scope="col">Borrow records</th>
</tr>
</thead>
<tbody>
<c:forEach var="row" items="${reportCenter.popularBooks}">
<tr>
<td>
<strong><c:out value="${row.bookIdentifier}" /></strong>
<div class="muted-text"><c:out value="${row.title}" /></div>
</td>
<td><c:out value="${row.author}" /></td>
<td><c:out value="${row.borrowCount}" /></td>
</tr>
</c:forEach>
</tbody>
</table>
</div>
</c:otherwise>
</c:choose>
</section>
</c:if>
</main>
</body>
</html>
@@ -45,6 +45,12 @@
<p>Create loans, process returns, renew records, and review overdue items.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/borrowing">Manage borrowing</a>
</article>
<article class="workspace-card">
<h2>Report Center</h2>
<p>Review inventory summaries, borrowing health, overdue lists, and popular books.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/reports">View reports</a>
</article>
</c:if>
<c:if test="${sessionScope.userRole == 'reader'}">
+9
View File
@@ -132,6 +132,15 @@
<url-pattern>/reader/loans</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>ReportServlet</servlet-name>
<servlet-class>com.mzh.library.controller.ReportServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>ReportServlet</servlet-name>
<url-pattern>/reports</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>UnauthorizedServlet</servlet-name>
<servlet-class>com.mzh.library.controller.UnauthorizedServlet</servlet-class>
+34
View File
@@ -238,6 +238,13 @@ h2 {
gap: 18px;
}
.report-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.workspace-card {
min-height: 190px;
display: flex;
@@ -254,6 +261,32 @@ h2 {
margin-top: auto;
}
.report-card {
min-height: 150px;
padding: 20px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-panel);
box-shadow: var(--shadow-panel);
}
.report-card h2 {
color: var(--color-muted);
font-size: 14px;
}
.report-card-alert {
border-color: rgba(181, 66, 56, 0.28);
}
.report-metric {
margin-bottom: 0;
color: var(--color-primary-strong);
font-size: 34px;
font-weight: 700;
line-height: 1;
}
.notice-panel {
max-width: 680px;
padding: 28px;
@@ -491,6 +524,7 @@ h2 {
.notice-panel,
.dashboard-hero,
.workspace-card,
.report-card,
.toolbar-panel,
.table-panel,
.form-panel {
@@ -11,12 +11,15 @@ public final class PermissionPolicyCheck {
PermissionPolicy policy = new PermissionPolicy();
require(policy.allows(Role.ADMINISTRATOR, Permission.MANAGE_USERS), "administrator should manage users");
require(policy.allows(Role.ADMINISTRATOR, Permission.VIEW_REPORTS), "administrator should view reports");
require(policy.allows(Role.LIBRARIAN, Permission.MANAGE_BORROWING), "librarian should manage borrowing");
require(policy.allows(Role.LIBRARIAN, Permission.MANAGE_READERS), "librarian should manage readers");
require(policy.allows(Role.LIBRARIAN, Permission.VIEW_REPORTS), "librarian should view reports");
require(!policy.allows(Role.LIBRARIAN, Permission.BORROW_BOOKS), "librarian should not borrow as a reader");
require(!policy.allows(Role.LIBRARIAN, Permission.MANAGE_USERS), "librarian should not manage users");
require(policy.allows(Role.READER, Permission.VIEW_CATALOG), "reader should view catalog");
require(policy.allows(Role.READER, Permission.BORROW_BOOKS), "reader should view borrowing capabilities");
require(!policy.allows(Role.READER, Permission.VIEW_REPORTS), "reader should not view reports");
require(!policy.allows(Role.READER, Permission.MANAGE_BORROWING), "reader should not manage borrowing");
require(!policy.allows(Role.READER, Permission.MANAGE_BOOKS), "reader should not manage books");
require(!policy.allows(Role.READER, Permission.MANAGE_READERS), "reader should not manage readers");
@@ -0,0 +1,159 @@
package com.mzh.library.service;
import com.mzh.library.dao.ReportDao;
import com.mzh.library.entity.AuthenticatedUser;
import com.mzh.library.entity.BorrowingSummary;
import com.mzh.library.entity.InventorySummary;
import com.mzh.library.entity.OverdueReportRow;
import com.mzh.library.entity.Permission;
import com.mzh.library.entity.PopularBookReportRow;
import com.mzh.library.entity.ReportCenter;
import com.mzh.library.entity.Role;
import com.mzh.library.exception.DaoException;
import com.mzh.library.service.impl.ReportServiceImpl;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
public final class ReportServiceCheck {
private static final String DENIED_MESSAGE = "You do not have permission to view reports.";
private static final String UNAVAILABLE_MESSAGE =
"Report service is temporarily unavailable. Please try again later.";
private ReportServiceCheck() {
}
public static void main(String[] args) {
Logger.getLogger(ReportServiceImpl.class.getName()).setLevel(Level.OFF);
ReportService service = new ReportServiceImpl(new InMemoryReportDao());
AuthenticatedUser administrator = user(5L, Role.ADMINISTRATOR);
AuthenticatedUser librarian = user(10L, Role.LIBRARIAN);
AuthenticatedUser reader = user(20L, Role.READER);
ServiceResult<ReportCenter> anonymousDenied = service.loadReportCenter(null);
requireDenied(anonymousDenied, "anonymous user should not view reports");
ServiceResult<ReportCenter> readerDenied = service.loadReportCenter(reader);
requireDenied(readerDenied, "reader should not view reports");
ServiceResult<ReportCenter> adminReport = service.loadReportCenter(administrator);
require(adminReport.isSuccessful(), "administrator should load report center");
ServiceResult<ReportCenter> report = service.loadReportCenter(librarian);
require(report.isSuccessful(), "librarian should load report center");
require(report.getData().getInventorySummary().getTotalTitles() == 4,
"inventory summary should expose total titles");
require(report.getData().getInventorySummary().getUnavailableOrEmptyTitles() == 2,
"inventory summary should expose unavailable or empty titles");
require(report.getData().getBorrowingSummary().getActiveLoans() == 3,
"borrowing summary should expose active loans");
require(report.getData().getBorrowingSummary().getOverdueLoans() == 1,
"borrowing summary should expose overdue loans");
require(report.getData().getOverdueRows().size() == 1,
"report center should include overdue rows");
require(report.getData().getPopularBooks().size() == 2,
"report center should include popular book rows");
ReportService failingService = new ReportServiceImpl(new FailingReportDao());
ServiceResult<ReportCenter> failure = failingService.loadReportCenter(librarian);
require(!failure.isSuccessful(), "DAO failure should not escape reports");
require(UNAVAILABLE_MESSAGE.equals(failure.getMessage()),
"DAO failure should map to safe report message");
}
private static void requireDenied(ServiceResult<ReportCenter> result, String message) {
require(!result.isSuccessful(), message);
require(DENIED_MESSAGE.equals(result.getMessage()),
"report denial should use the report permission message");
}
private static AuthenticatedUser user(long id, Role role) {
return new AuthenticatedUser(id, role.getCode(), role.getDisplayName(), role,
role == Role.READER
? EnumSet.of(Permission.VIEW_CATALOG, Permission.BORROW_BOOKS)
: EnumSet.of(Permission.MANAGE_BORROWING, Permission.VIEW_REPORTS, Permission.VIEW_CATALOG));
}
private static void require(boolean condition, String message) {
if (!condition) {
throw new AssertionError(message);
}
}
private static final class InMemoryReportDao implements ReportDao {
@Override
public InventorySummary loadInventorySummary() {
InventorySummary summary = new InventorySummary();
summary.setTotalTitles(4);
summary.setTotalCopies(12);
summary.setAvailableCopies(7);
summary.setUnavailableOrEmptyTitles(2);
return summary;
}
@Override
public BorrowingSummary loadBorrowingSummary() {
BorrowingSummary summary = new BorrowingSummary();
summary.setActiveLoans(3);
summary.setReturnedLoans(5);
summary.setOverdueLoans(1);
return summary;
}
@Override
public List<OverdueReportRow> findOverdueRows() {
OverdueReportRow row = new OverdueReportRow();
row.setReaderIdentifier("RD-1000");
row.setReaderName("Active Reader");
row.setBookIdentifier("BK-1000");
row.setBookTitle("Effective Java");
row.setDueAt(LocalDateTime.of(2026, 4, 1, 12, 0));
row.setOverdueDays(26);
return Collections.singletonList(row);
}
@Override
public List<PopularBookReportRow> findPopularBooks(int limit) {
PopularBookReportRow first = popularBook("BK-1000", "Effective Java", "Joshua Bloch", 5);
PopularBookReportRow second = popularBook("BK-1001", "Clean Code", "Robert C. Martin", 3);
return Arrays.asList(first, second);
}
private PopularBookReportRow popularBook(String identifier, String title, String author, int borrowCount) {
PopularBookReportRow row = new PopularBookReportRow();
row.setBookIdentifier(identifier);
row.setTitle(title);
row.setAuthor(author);
row.setBorrowCount(borrowCount);
return row;
}
}
private static final class FailingReportDao implements ReportDao {
@Override
public InventorySummary loadInventorySummary() {
throw new DaoException("Simulated inventory report failure", null);
}
@Override
public BorrowingSummary loadBorrowingSummary() {
throw new DaoException("Simulated borrowing report failure", null);
}
@Override
public List<OverdueReportRow> findOverdueRows() {
throw new DaoException("Simulated overdue report failure", null);
}
@Override
public List<PopularBookReportRow> findPopularBooks(int limit) {
throw new DaoException("Simulated popular report failure", null);
}
}
}