Next PMS — Developer Documentation
Technical reference for developers working on next_pms. For end-user docs see the hosted guide at /pms-guide (source: next_pms/www/pms-guide.html).
Architecture
next_pms/ Frappe app (Python, v15)
├── next_pms/
│ ├── api/ Whitelisted HTTP endpoints (SPA backend)
│ ├── next_pms/doctype/ DocTypes + controllers
│ ├── www/ Public web pages (user-guide, pms-portal)
│ ├── public/frontend/ BUILT Vue SPA (committed — see Deploy)
│ ├── tasks.py Scheduler jobs (emails, reminders, alerts)
│ └── hooks.py Scheduler events, fixtures, assets
├── frontend/ Vue 3 + Vite + Pinia SPA source
│ └── src/{views,components,store,utils}
├── android-capacitor/ Capacitor Android wrapper
└── docs/ This documentation
- SPA served at
/next-pmsfromnext_pms/public/frontend(vite build output). - Client portal at
/pms-portal?token=…— server-rendered, token-auth, no login. - Desk is secondary; primary UX is the SPA.
DocTypes
| DocType | Purpose | Notes |
|---|---|---|
| PMS Project | Project + budget + team + portal settings | budget_utilization auto-computed; 80% alert |
| PMS Project Member | Child: member + hourly rate | Rate feeds task cost |
| PMS Sprint | Sprint with date range | Planning → Active → Completed |
| PMS Task | Task | estimated_hours PM-set; actual_hours timer-derived; statuses Backlog/To Do/In Progress/In Review/Done |
| PMS Time Log | Timer entry | The only "actual hours" source in the entire app |
| PMS Checkin | Attendance | Informational; never a work-hours baseline |
| PMS Comment | Task comments | ⚠️ fields are comment and user — NOT content/author |
| PMS Meeting | Calendar meetings | Attendees child table; MoM mandatory |
| Weekly Plan (+ children) | Weekly allocations matrix | published=1 plans feed Plan Adherence |
| PMS AI Settings | Single: report/email config | working_hours_per_day, recipients, toggles |
| PMS Client Portal Access | Portal tokens | |
| PMS Performance Score | Frozen monthly score snapshot | Submittable, PERF-{YYYY-MM}-{user} (structural one-per-member-month). Frozen fields read-only + no allow_on_submit; override fields (adjustment, adjustment_reason, adjusted_by, adjusted_on, final_score, final_band) carry allow_on_submit=1. No role has delete/cancel/amend. track_changes=1 → Version audit. |
| PMS Performance Dimension | Child of Performance Score | Keeps per-dimension dim_key/weight/included/score/raw_basis so the human-readable basis survives the freeze. |
API layer (next_pms/api/)
All SPA calls go through whitelisted methods here. Key modules:
| Module | Responsibility |
|---|---|
_hours.py |
Single source of truth for target/utilization math (see Metrics Engine) |
productivity.py |
Employee Productivity tab (get_employee_productivity) |
performance.py |
Composite Performance Score: get_performance_score (individual, custom from_date/to_date or rolling period_days), get_team_performance (ranked leaderboard), compute_team_performance (internal, used by monthly cron), get_score_history (submitted snapshots for a user, newest first), apply_adjustment(name, adjustment, reason) (±10 post-submit override — fresh get_doc + single save(), never db_set+save). All management-only. |
crud.py |
Task report, task/project CRUD |
weekly_plan.py |
Weekly Plan matrix builder |
calendar.py |
Meetings |
ai_report.py |
Daily AI report generation |
permissions.py |
is_admin_user(), is_manager_user(), get_user_projects() |
portal.py |
Client portal (token auth) |
timer.py, checkin.py, notifications.py, settings.py, users.py |
Self-explanatory |
Hard rules (production incidents happened — respect these)
- PMS Comment fields are
commentanduser— notcontent/author. - All portal
frappe.get_all()needignore_permissions=True— PMS Customer users lack doctype read perms. - Never
frappe.get_doc()in portal APIs — usefrappe.db.get_value()/frappe.get_all(..., ignore_permissions=True). - Whitelisted methods that expose cross-user data must gate on
is_admin_user() or is_manager_user()andfrappe.throwotherwise. - Coerce inputs with
cint/flt/cstr/getdatefromfrappe.utils, never bareint()/float(). - No f-string SQL, ever.
frappe.qbor parameterisedfrappe.db.sql.
Metrics engine
_hours.py — the shared basis
Every report derives "how many hours should this person have worked" from here so numbers stay consistent:
target_hours = effective_working_days × working_hours_per_day (default 8)
effective_working_days = all days in range
− Sundays
− holidays (employee's Holiday List, weekly_off=0)
− full-day approved leave (docstatus != 2!)
− 0.5 × half-day approved leave
PMS Time Log.duration_hoursis the only "actual hours" source. Check-in/out is informational.- Frappe quirk handled here: cancelled Leave Applications keep
status='Approved'withdocstatus=2— must exclude docstatus 2 explicitly.
The two headline metrics — do not conflate
| Utilization | Efficiency | |
|---|---|---|
| Formula | logged ÷ target × 100 | estimated ÷ actual × 100 |
| Question | enough hours vs the bar? | estimates accurate? (>100% = faster) |
Windows also differ by surface: weekly email = fixed Mon–Fri week (Saturday cron, Saturday excluded from target); Task Report periods = rolling N days ending today. Same engine, different slice — both metrics are now labelled with their formula wherever shown.
Performance Score (performance.py)
Management-only composite, 8 dimensions, weights fixed in WEIGHTS (sum 100):
| Dimension | Weight | Formula (each scored 0–100) |
|---|---|---|
| delivery | 25 | Σ est. hours of completed tasks ÷ target, cap 100 |
| timeliness | 15 | on-time ÷ due-dated completions |
| utilization | 15 | logged ÷ target, cap 100 |
| plan_adherence | 15 | hours on published-Weekly-Plan projects ÷ planned, cap 100 |
| efficiency | 10 | est ÷ actual, capped EFFICIENCY_CAP=120, normalised /120 |
| quality | 10 | 1 − reopened ÷ completed (reopen = Version history shows status Done→other) |
| consistency | 5 | days with ≥50% daily target logged ÷ working days |
| attendance | 5 | checked-in ÷ working days |
- Composite = Σ(weight × score) ÷ Σ included weights. Dimensions with no data are excluded and weights renormalised — missing data never scores zero.
- "Completed in window" proxy =
status='Done' AND modified BETWEEN window(PMS Task has no completion_date — roadmap item). - Bands: A ≥ 85, B ≥ 70, C ≥ 50, else D.
- Methodology is user-documented inside the Performance tab (
PerformanceTab.vue) — keep code, tab docs and this file in sync when changing formulas.
Scheduler jobs (tasks.py + hooks.py)
| Job | Cron | What |
|---|---|---|
send_weekly_summary |
Sat 07:00 | Per-member email + management team table (Utilization + Efficiency) |
send_monthly_performance_report |
1st of month 08:00 | Three phases: (1) compute team once; (2) _upsert_month_snapshots — create+SUBMIT one PMS Performance Score per ranked member (deterministic names → idempotent: submitted = skip, crashed draft = replace, unranked members get none), then frappe.db.commit(); (3) emails built FROM the frozen snapshots. Pure re-run creates nothing and sends nothing. Caveat: a partial re-run that ranks a NEW member (e.g. backfilled logs) re-emails the whole month. Toggle: monthly_performance_enabled (getattr default ON). |
generate_daily_report (ai_report.py) |
daily 03:00 | AI daily report to management |
send_checkin_reminders |
per config | Missing check-in/out nudges |
| Budget alerts | on update | ≥80% budget utilisation |
⚠️ After deploying scheduler-code changes, restart workers — stale workers silently run old code (2026-06-18 incident: 4-day silent outage).
⚠️ A new cron entry in hooks.py needs a Scheduled Job Type sync to start firing. Full bench migrate does it, but when migrate is risky, sync just the jobs:
# bench --site <site> console
from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs
sync_jobs(); frappe.db.commit()
Frontend (Vue 3 + Vite + Pinia)
- Views in
frontend/src/views, shared components infrontend/src/components. - API calls:
import { call } from '@/utils/frappe'→call('next_pms.api.module.method', {args}). - Role gates:
useSettingsStore()→isAdmin,isManager,canViewFinance. Gate both the UI (v-if) and the backend (throw) — UI hiding alone is not security. - Dev:
npx vite --port 8081(proxies/apitolocalhost:8000). Local site:mysite.local—bench serveservessites/currentsite.txt; if the SPA says "App next_pms is not installed", you're on the wrong current site (bench use mysite.local).
Build & Deploy
Built SPA assets are committed to the repo (next_pms/public/frontend). Production deploy is therefore pull-only:
# 1. Local: build + commit
cd frontend && yarn build # outputs to next_pms/public/frontend
git add -A && git commit && git push
# 2. Server (as bench user)
cd frappe-bench/apps/next_pms && git pull origin main
# 3. Restart (root — `bench restart` fails as bench user on office)
supervisorctl restart all
# 4. Only if a www/ page changed:
bench --site <site> clear-website-cache
# 5. Only if DocType JSON / fixtures / hooks cron changed:
bench --site <site> migrate # ⚠️ see migrate warning below
yarn buildinfrontend/≠bench build. The former builds the SPA; the latter bundles Frappe assets. SPA changes need the former.- ⚠️ Migrate on office is currently booby-trapped by an HRMS web-form duplicate. For new doctypes prefer targeted
frappe.reload_doc(module, "doctype", "name")over full migrate; always take a DB backup first. - Multiple sites exist on production benches — verify the site before any
bench --sitecommand.
Testing
bench --site mysite.local run-tests --app next_pms # all
bench --site mysite.local run-tests --module next_pms.api.test_hours
Tests live beside the modules (api/test_*.py) and in doctype folders (test_<doctype>.py), inheriting FrappeTestCase. Anything touching money, hours math, or permissions needs a test.
Roadmap
| Status | Item | Notes |
|---|---|---|
| Shipped | Monthly Performance Score snapshots | PMS Performance Score + dimension child table, cron-frozen, Score History card. |
| Shipped | Management adjustment ±10 with mandatory reason | apply_adjustment endpoint + inline editor; Version audit trail. |
| Planned | completion_date on PMS Task |
Replace the "Done + modified-in-window" proxy in performance/email metrics. |
| Planned | PMS Performance Settings (Single) | Management-tunable WEIGHTS/caps; replaces hardcoded dict in performance.py. |
| Planned | Android APK release | Capacitor build in android-capacitor/. |
Performance snapshot lifecycle (how to work with it)
1st 08:00 cron ─► compute_team_performance(prev month)
─► _upsert_month_snapshots() → insert + submit (docstatus 1 = FROZEN)
─► commit
─► member emails + leaderboard email, FROM the snapshots
- Freeze guarantee: any post-submit write to a non-
allow_on_submitfield raisesUpdateAfterSubmitError(core, tested). Snapshots cannot be deleted/cancelled/amended by any role. - Adjustment path:
apply_adjustment→ gate → freshfrappe.get_doc→ set override fields in memory → onedoc.save(). Controller validates ±10 + mandatory reason in BOTHvalidateandbefore_update_after_submit(plainvalidatedoes not run post-submit in v15).final_score = clamp(composite + adj, 0, 100),final_bandrecomputed via the shared_band. Version row records old→new (audit trail). - Trend:
get_score_history(user)→ Score History card in PerformanceTab Individual view. - Tests:
next_pms/api/test_performance_snapshots.py(22 cases: naming, freeze matrix, idempotency, clamps, reason rule, gates, Version audit, email idempotency). Run:bench --site mysite.local run-tests --app next_pms --module next_pms.api.test_performance_snapshots.
Deploying a NEW DocType to office (migrate is broken there)
# bench --site office console — child table FIRST, then parent
frappe.reload_doc("next_pms", "doctype", "pms_performance_dimension")
frappe.reload_doc("next_pms", "doctype", "pms_performance_score")
frappe.db.commit()
Then restart workers (root supervisorctl restart all). Never bench migrate on office until the HRMS web-form duplicate is fixed. New cron hooks additionally need sync_jobs() (see above).
Settings reference (PMS AI Settings, Single)
| Field | Used by |
|---|---|
working_hours_per_day |
_hours.get_working_hours_per_day() — every target/utilization figure (default 8) |
daily_report_enabled / daily_report_recipient(s) / report_detail_level / plan_department |
ai_report.generate_daily_report |
ai_* / fallback_* |
LLM config for the daily report |
weekly_summary_enabled / weekly_summary_recipient |
tasks.send_weekly_summary — recipient is ALSO the monthly-leaderboard inbox |
monthly_performance_enabled (optional field) |
tasks.send_monthly_performance_report — read via getattr(..., 1) so it works before the field exists |
attendance_reminder_enabled / attendance_manager_recipients |
tasks.send_checkin_reminders |
budget_approver_emails |
Budget request approvals |
Pattern: new toggles are read with getattr(frappe.get_cached_doc("PMS AI Settings"), "<field>", <default>) so code deploys before schema — add the actual Check field later for UI control, no code change needed.
Release process
# 1. Bump version (both files must match)
# next_pms/__init__.py → __version__
# pyproject.toml → version
# 2. Commit, push, tag
git tag -a v<X.Y.Z> -m "next_pms v<X.Y.Z>"
git push origin main --tags
# 3. GitHub release with generated notes
gh release create v<X.Y.Z> --title "next_pms v<X.Y.Z>" --notes-file <notes.md>
Semver: MAJOR = breaking DocType/API change, MINOR = features (new endpoints, tabs, emails), PATCH = fixes.
Related docs
README.md— features, install, access pointsPERMISSIONS.md— role system deep-dive/pms-guide(hosted) — end-user documentationCLAUDE.md— AI-agent project memory (safety rules, session logs)