forked from ebhomengo/niki
Implement patient list end point
This commit is contained in:
parent
cfe3b6521c
commit
06b2b3478d
|
|
@ -1 +1,34 @@
|
||||||
package patientapp
|
package patientapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.gocasts.ir/ebhomengo/niki/patientapp/delivery/http/analytic"
|
||||||
|
"git.gocasts.ir/ebhomengo/niki/patientapp/repository/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Application struct {
|
||||||
|
Config Config
|
||||||
|
HTTPServer *http.Server
|
||||||
|
DB *database.DataBase
|
||||||
|
Router http.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func Setup(config Config, server *http.Server, conn *database.DataBase) Application {
|
||||||
|
handler := analytic.NewHandler()
|
||||||
|
router := analytic.NewRouter(handler)
|
||||||
|
|
||||||
|
return Application{
|
||||||
|
Config: config,
|
||||||
|
HTTPServer: server,
|
||||||
|
DB: conn,
|
||||||
|
Router: router,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Application) Start() {
|
||||||
|
srv := &http.Server{Addr: a.HTTPServer.Addr}
|
||||||
|
server := analytic.NewServer(srv, a.Router)
|
||||||
|
|
||||||
|
_ = server.Serve()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
package patientapp
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.gocasts.ir/ebhomengo/niki/patientapp/service/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ListPatientAnalyticRequest struct {
|
||||||
|
// All fields are optional
|
||||||
|
MinAge *int `json:"minAge,omitempty"`
|
||||||
|
MaxAge *int `json:"maxAge,omitempty"`
|
||||||
|
Sex *entity.Sex `json:"sex,omitempty"`
|
||||||
|
|
||||||
|
City *int64 `json:"city,omitempty"`
|
||||||
|
Province *int64 `json:"province,omitempty"`
|
||||||
|
|
||||||
|
Search *string `json:"search,omitempty"`
|
||||||
|
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
Offset int `json:"offset,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PatientAnalyticItem struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
LastName string `json:"Last_name"`
|
||||||
|
DateOfBirth *time.Time `json:"dob,omitempty"`
|
||||||
|
Sex entity.Sex `json:"sex"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Address entity.Address `json:"address"`
|
||||||
|
}
|
||||||
|
type PatientAnalyticResponse struct {
|
||||||
|
Items []PatientAnalyticItem `json:"items"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToPatientResponse(patient entity.Patient) PatientAnalyticItem {
|
||||||
|
return PatientAnalyticItem{
|
||||||
|
ID: patient.ID,
|
||||||
|
FirstName: patient.FirstName,
|
||||||
|
LastName: patient.LastName,
|
||||||
|
DateOfBirth: patient.DateOfBirth,
|
||||||
|
Sex: patient.Sex,
|
||||||
|
Phone: patient.Phone,
|
||||||
|
Address: entity.Address{
|
||||||
|
ProvinceID: patient.Address.ProvinceID,
|
||||||
|
CityID: patient.Address.CityID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================== Map ==================================
|
||||||
|
|
||||||
|
type GetPatientMapSummaryRequest struct {
|
||||||
|
Level entity.MapLevel `json:"level"`
|
||||||
|
ParentID *int `json:"parentID"`
|
||||||
|
|
||||||
|
MinAge *int `json:"minAge,omitempty"`
|
||||||
|
MaxAge *int `json:"maxAge,omitempty"`
|
||||||
|
Sex *entity.Sex `json:"sex,omitempty"`
|
||||||
|
Search *string `json:"search,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetPatientMapSummaryResponse struct {
|
||||||
|
Level entity.MapLevel `json:"level"`
|
||||||
|
Items []entity.MapSummaryItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
package analytic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.gocasts.ir/ebhomengo/niki/patientapp/delivery/http/analytic/dto"
|
||||||
|
svc "git.gocasts.ir/ebhomengo/niki/patientapp/service/analytic"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service svc.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(service svc.Service) *Handler {
|
||||||
|
return &Handler{
|
||||||
|
service: service,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) PatientsAnalytic(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
|
||||||
|
minAge, err := parseIntPtr(q, "minAge")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid minAge")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
maxAge, err := parseIntPtr(q, "maxAge")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid maxAge")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sex, err := parseSexPtr(q, "sex")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid sex")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
provinceID, err := parseIntPtr(q, "provinceId")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid provinceId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cityID, err := parseIntPtr(q, "cityId")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid cityId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
search := strings.TrimSpace(q.Get("search"))
|
||||||
|
var searchPtr *string
|
||||||
|
if search != "" {
|
||||||
|
searchPtr = &search
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, err := parseInt(q, "limit")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid limit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
offset, err := parseInt(q, "offset")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid offset")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pId := int64(*provinceID)
|
||||||
|
cId := int64(*cityID)
|
||||||
|
|
||||||
|
req := dto.ListPatientAnalyticRequest{
|
||||||
|
MinAge: minAge,
|
||||||
|
MaxAge: maxAge,
|
||||||
|
Sex: sex,
|
||||||
|
Province: &pId,
|
||||||
|
City: &cId,
|
||||||
|
Search: searchPtr,
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.service.List(r.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("List error:", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, fmt.Errorf("List error: %w", err).Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) PatientsMapSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
|
||||||
|
level, err := parseMapLevel(q, "level")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid level (city|province|country)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parentID, err := parseIntPtr(q, "parentId")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid parentId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
minAge, err := parseIntPtr(q, "minAge")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid minAge")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
maxAge, err := parseIntPtr(q, "maxAge")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid maxAge")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sex, err := parseSexPtr(q, "sex")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid sex")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
search := strings.TrimSpace(q.Get("search"))
|
||||||
|
var searchPtr *string
|
||||||
|
if search != "" {
|
||||||
|
searchPtr = &search
|
||||||
|
}
|
||||||
|
|
||||||
|
req := dto.GetPatientMapSummaryRequest{
|
||||||
|
Level: level,
|
||||||
|
ParentID: parentID,
|
||||||
|
MinAge: minAge,
|
||||||
|
MaxAge: maxAge,
|
||||||
|
Sex: sex,
|
||||||
|
Search: searchPtr,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, svcErr := h.service.GetMapSummary(r.Context(), req)
|
||||||
|
if svcErr != nil {
|
||||||
|
log.Println("GetMapSummary error:", svcErr)
|
||||||
|
writeError(w, http.StatusInternalServerError, svcErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
package analytic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.gocasts.ir/ebhomengo/niki/patientapp/service/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]any{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt(q urlValues, key string) (int, error) {
|
||||||
|
val := strings.TrimSpace(q.Get(key))
|
||||||
|
if val == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return strconv.Atoi(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
type urlValues interface {
|
||||||
|
Get(key string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIntPtr(q urlValues, key string) (*int, error) {
|
||||||
|
val := strings.TrimSpace(q.Get(key))
|
||||||
|
if val == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
i, err := strconv.Atoi(val)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &i, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSexPtr(q urlValues, key string) (*entity.Sex, error) {
|
||||||
|
val := strings.TrimSpace(q.Get(key))
|
||||||
|
if val == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
s := entity.Sex(val)
|
||||||
|
if !s.SexValidation() {
|
||||||
|
return nil, strconv.ErrSyntax
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMapLevel(q urlValues, key string) (entity.MapLevel, error) {
|
||||||
|
val := strings.TrimSpace(q.Get(key))
|
||||||
|
if val == "" {
|
||||||
|
return "", strconv.ErrSyntax
|
||||||
|
}
|
||||||
|
l := entity.MapLevel(val)
|
||||||
|
switch l {
|
||||||
|
case entity.MapLevelCity, entity.MapLevelProvince, entity.MapLevelCountry:
|
||||||
|
return l, nil
|
||||||
|
default:
|
||||||
|
return "", strconv.ErrSyntax
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package analytic
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func NewRouter(h *Handler) http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Define Routes
|
||||||
|
v1 := http.NewServeMux()
|
||||||
|
v1.HandleFunc("GET /health", h.Health)
|
||||||
|
v1.HandleFunc("GET /patients", h.PatientsAnalytic)
|
||||||
|
v1.HandleFunc("GET /patients-summary", h.PatientsMapSummary)
|
||||||
|
|
||||||
|
mux.Handle("/v1/", http.StripPrefix("/v1", v1))
|
||||||
|
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package analytic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
HTTPServer *http.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(server *http.Server, router http.Handler) *Server {
|
||||||
|
server.Handler = router
|
||||||
|
return &Server{
|
||||||
|
HTTPServer: server,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Server) Serve() error {
|
||||||
|
// Start server
|
||||||
|
fmt.Printf("start sever on %s \n", s.HTTPServer.Addr)
|
||||||
|
return s.HTTPServer.ListenAndServe()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,303 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.gocasts.ir/ebhomengo/niki/patientapp/service/analytic"
|
||||||
|
"git.gocasts.ir/ebhomengo/niki/patientapp/service/entity"
|
||||||
|
errmsg "git.gocasts.ir/ebhomengo/niki/pkg/err_msg"
|
||||||
|
richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error"
|
||||||
|
"git.gocasts.ir/ebhomengo/niki/repository/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DataBase struct {
|
||||||
|
conn *mysql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPatientRepo(conn *mysql.DB) *DataBase {
|
||||||
|
return &DataBase{
|
||||||
|
conn: conn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DataBase) GetPatients(ctx context.Context, f analytic.PatientFilter) ([]entity.Patient, error) {
|
||||||
|
const op = "mysqlpatient.GetPatients"
|
||||||
|
limit, offset := clampLimitOffset(f.Limit, f.Offset)
|
||||||
|
|
||||||
|
q := `
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.first_name,
|
||||||
|
p.last_name,
|
||||||
|
p.date_of_birth,
|
||||||
|
p.sex,
|
||||||
|
p.phone,
|
||||||
|
|
||||||
|
p.country_id,
|
||||||
|
p.province_id,
|
||||||
|
p.city_id,
|
||||||
|
p.postal_code,
|
||||||
|
p.address_line1,
|
||||||
|
p.address_line2,
|
||||||
|
|
||||||
|
p.case_status,
|
||||||
|
p.referral_source,
|
||||||
|
p.assigned_staff_id,
|
||||||
|
p.start_date,
|
||||||
|
p.end_date
|
||||||
|
FROM patients p
|
||||||
|
WHERE p.deleted_at IS NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
stmt, err := db.conn.PrepareStatement(ctx, mysql.StatementKeyCityGetProvinceIDByID, q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, richerror.New(op).WithErr(err).
|
||||||
|
WithMessage(errmsg.ErrorMsgCantPrepareStatement).WithKind(richerror.KindUnexpected)
|
||||||
|
}
|
||||||
|
args := make([]any, 0)
|
||||||
|
|
||||||
|
// filters
|
||||||
|
if f.Sex != nil {
|
||||||
|
q += " AND p.sex = ?"
|
||||||
|
args = append(args, string(*f.Sex))
|
||||||
|
}
|
||||||
|
if f.DOBFrom != nil {
|
||||||
|
q += " AND p.date_of_birth >= ?"
|
||||||
|
args = append(args, f.DOBFrom.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
if f.DOBTo != nil {
|
||||||
|
q += " AND p.date_of_birth <= ?"
|
||||||
|
args = append(args, f.DOBTo.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
if f.Country != nil {
|
||||||
|
q += " AND p.country_id = ?"
|
||||||
|
args = append(args, *f.Country)
|
||||||
|
}
|
||||||
|
if f.Province != nil {
|
||||||
|
q += " AND p.province_id = ?"
|
||||||
|
args = append(args, *f.Province)
|
||||||
|
}
|
||||||
|
if f.City != nil {
|
||||||
|
q += " AND p.city_id = ?"
|
||||||
|
args = append(args, *f.City)
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.Search != nil && strings.TrimSpace(*f.Search) != "" {
|
||||||
|
term := "%" + strings.TrimSpace(*f.Search) + "%"
|
||||||
|
q += " AND (p.first_name LIKE ? OR p.last_name LIKE ? OR p.phone LIKE ?)"
|
||||||
|
args = append(args, term, term, term)
|
||||||
|
}
|
||||||
|
|
||||||
|
q += " ORDER BY p.id DESC LIMIT ? OFFSET ?"
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
|
rows, err := stmt.QueryContext(ctx, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make([]entity.Patient, 0, limit)
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var p entity.Patient
|
||||||
|
var dob sql.NullTime
|
||||||
|
var provinceID, cityID uint
|
||||||
|
|
||||||
|
if err := rows.Scan(
|
||||||
|
&p.ID,
|
||||||
|
&p.FirstName,
|
||||||
|
&p.LastName,
|
||||||
|
&dob,
|
||||||
|
&p.Sex,
|
||||||
|
&p.Phone,
|
||||||
|
|
||||||
|
&provinceID,
|
||||||
|
&cityID,
|
||||||
|
&p.Address.PostalCode,
|
||||||
|
|
||||||
|
&p.CaseStatus,
|
||||||
|
&p.ReferralSource,
|
||||||
|
&p.AssignedStaffId,
|
||||||
|
&p.StartDate,
|
||||||
|
&p.EndDate,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if dob.Valid {
|
||||||
|
t := dob.Time
|
||||||
|
p.DateOfBirth = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Address.ProvinceID = provinceID
|
||||||
|
p.Address.CityID = cityID
|
||||||
|
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DataBase) CountPatients(ctx context.Context, f analytic.PatientFilter) (int, error) {
|
||||||
|
const op = "mysqlpatient.CountPatients"
|
||||||
|
q := `SELECT COUNT(*) FROM patients p WHERE p.deleted_at IS NULL`
|
||||||
|
args := make([]any, 0)
|
||||||
|
|
||||||
|
stmt, err := db.conn.PrepareStatement(ctx, mysql.StatementKeyCityGetProvinceIDByID, q)
|
||||||
|
if err != nil {
|
||||||
|
return 0, richerror.New(op).WithErr(err).
|
||||||
|
WithMessage(errmsg.ErrorMsgCantPrepareStatement).WithKind(richerror.KindUnexpected)
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.Sex != nil {
|
||||||
|
q += " AND p.sex = ?"
|
||||||
|
args = append(args, string(*f.Sex))
|
||||||
|
}
|
||||||
|
if f.DOBFrom != nil {
|
||||||
|
q += " AND p.date_of_birth >= ?"
|
||||||
|
args = append(args, f.DOBFrom.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
if f.DOBTo != nil {
|
||||||
|
q += " AND p.date_of_birth <= ?"
|
||||||
|
args = append(args, f.DOBTo.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
if f.Country != nil {
|
||||||
|
q += " AND p.country_id = ?"
|
||||||
|
args = append(args, *f.Country)
|
||||||
|
}
|
||||||
|
if f.Province != nil {
|
||||||
|
q += " AND p.province_id = ?"
|
||||||
|
args = append(args, *f.Province)
|
||||||
|
}
|
||||||
|
if f.City != nil {
|
||||||
|
q += " AND p.city_id = ?"
|
||||||
|
args = append(args, *f.City)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int
|
||||||
|
|
||||||
|
err = stmt.QueryRowContext(ctx, args...).Scan(&total)
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DataBase) SummaryByCity(ctx context.Context, provinceID int, f analytic.PatientMapFilter) ([]entity.MapSummaryItem, error) {
|
||||||
|
const op = "mysqlpatient.SummaryByCity"
|
||||||
|
q := `
|
||||||
|
SELECT
|
||||||
|
c.id AS location_id,
|
||||||
|
c.name,
|
||||||
|
c.centroid_lat,
|
||||||
|
c.centroid_lng,
|
||||||
|
COUNT(p.id) AS count
|
||||||
|
FROM locations c
|
||||||
|
JOIN patients p ON p.city_id = c.id
|
||||||
|
WHERE c.level = 'city'
|
||||||
|
AND c.parent_id = ?
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
stmt, err := db.conn.PrepareStatement(ctx, mysql.StatementKeyCityGetProvinceIDByID, q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, richerror.New(op).WithErr(err).
|
||||||
|
WithMessage(errmsg.ErrorMsgCantPrepareStatement).WithKind(richerror.KindUnexpected)
|
||||||
|
}
|
||||||
|
args := []any{provinceID}
|
||||||
|
|
||||||
|
if f.Sex != nil {
|
||||||
|
q += " AND p.sex = ?"
|
||||||
|
args = append(args, string(*f.Sex))
|
||||||
|
}
|
||||||
|
if f.MinDOB != nil {
|
||||||
|
q += " AND p.date_of_birth >= ?"
|
||||||
|
args = append(args, f.MinDOB.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
if f.MaxDOB != nil {
|
||||||
|
q += " AND p.date_of_birth <= ?"
|
||||||
|
args = append(args, f.MaxDOB.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
if f.Search != nil && strings.TrimSpace(*f.Search) != "" {
|
||||||
|
term := "%" + strings.TrimSpace(*f.Search) + "%"
|
||||||
|
q += " AND (p.first_name LIKE ? OR p.last_name LIKE ? OR p.phone LIKE ?)"
|
||||||
|
args = append(args, term, term, term)
|
||||||
|
}
|
||||||
|
|
||||||
|
q += ` GROUP BY c.id, c.name, c.centroid_lat, c.centroid_lng
|
||||||
|
ORDER BY count DESC`
|
||||||
|
|
||||||
|
rows, err := stmt.QueryContext(ctx, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []entity.MapSummaryItem
|
||||||
|
for rows.Next() {
|
||||||
|
var it entity.MapSummaryItem
|
||||||
|
if err := rows.Scan(&it.LocationID, &it.Name, &it.CentroidLat, &it.CentroidLng, &it.Count); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, it)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DataBase) SummaryByProvince(ctx context.Context, countryID int, f analytic.PatientMapFilter) ([]entity.MapSummaryItem, error) {
|
||||||
|
const op = "mysqlpatient.SummaryByProvince"
|
||||||
|
|
||||||
|
q := `
|
||||||
|
SELECT
|
||||||
|
prov.id AS location_id,
|
||||||
|
prov.name,
|
||||||
|
prov.centroid_lat,
|
||||||
|
prov.centroid_lng,
|
||||||
|
COUNT(p.id) AS count
|
||||||
|
FROM patients p
|
||||||
|
JOIN locations city ON city.id = p.city_id AND city.level = 'city'
|
||||||
|
JOIN locations prov ON prov.id = city.parent_id AND prov.level = 'province'
|
||||||
|
WHERE prov.parent_id = ?
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
stmt, err := db.conn.PrepareStatement(ctx, mysql.StatementKeyCityGetProvinceIDByID, q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, richerror.New(op).WithErr(err).
|
||||||
|
WithMessage(errmsg.ErrorMsgCantPrepareStatement).WithKind(richerror.KindUnexpected)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []any{countryID}
|
||||||
|
|
||||||
|
if f.Sex != nil {
|
||||||
|
q += " AND p.sex = ?"
|
||||||
|
args = append(args, string(*f.Sex))
|
||||||
|
}
|
||||||
|
if f.MinDOB != nil {
|
||||||
|
q += " AND p.date_of_birth >= ?"
|
||||||
|
args = append(args, f.MinDOB.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
if f.MaxDOB != nil {
|
||||||
|
q += " AND p.date_of_birth <= ?"
|
||||||
|
args = append(args, f.MaxDOB.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
|
||||||
|
q += ` GROUP BY prov.id, prov.name, prov.centroid_lat, prov.centroid_lng
|
||||||
|
ORDER BY count DESC`
|
||||||
|
|
||||||
|
rows, err := stmt.QueryContext(ctx, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []entity.MapSummaryItem
|
||||||
|
for rows.Next() {
|
||||||
|
var it entity.MapSummaryItem
|
||||||
|
if err := rows.Scan(&it.LocationID, &it.Name, &it.CentroidLat, &it.CentroidLng, &it.Count); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, it)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
postgres_db:
|
||||||
|
driver: postgres
|
||||||
|
host: patient-db
|
||||||
|
port: 5432
|
||||||
|
user: patient_admin
|
||||||
|
password: password1234
|
||||||
|
db_name: patient_db
|
||||||
|
ssl_mode: disable
|
||||||
|
|
||||||
|
path_of_migration: ./patientapp/repository/database/migrations
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
type whereClause struct {
|
||||||
|
sql strings.Builder
|
||||||
|
args []any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *whereClause) add(cond string, args ...any) {
|
||||||
|
if w.sql.Len() == 0 {
|
||||||
|
w.sql.WriteString(" WHERE ")
|
||||||
|
} else {
|
||||||
|
w.sql.WriteString(" AND ")
|
||||||
|
}
|
||||||
|
w.sql.WriteString(cond)
|
||||||
|
w.args = append(w.args, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *whereClause) String() string { return w.sql.String() }
|
||||||
|
|
||||||
|
func clampLimitOffset(limit, offset int) (int, int) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
return limit, offset
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package analytic
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
func normalizeLimitOffset(limit, offset int) (int, int) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
return limit, offset
|
||||||
|
}
|
||||||
|
|
||||||
|
// convert age range -> DOB range
|
||||||
|
func ageRangeToDOB(minAge, maxAge *int, now time.Time) (dobFrom, dobTo *time.Time) {
|
||||||
|
// maxAge => DOB >= now-(maxAge+1y)+1day
|
||||||
|
// eg: now is : 3/31/2026; maxAge=30 => (now - 31)= 3/31/1994 => +=1
|
||||||
|
if maxAge != nil {
|
||||||
|
t := now.AddDate(-*maxAge-1, 0, 0).Add(24 * time.Hour)
|
||||||
|
dobFrom = &t
|
||||||
|
}
|
||||||
|
// minAge => DOB <= now-minAge
|
||||||
|
if minAge != nil {
|
||||||
|
t := now.AddDate(-*minAge, 0, 0)
|
||||||
|
dobTo = &t
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package analytic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.gocasts.ir/ebhomengo/niki/patientapp/service/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PatientFilter struct {
|
||||||
|
DOBFrom *time.Time // born after
|
||||||
|
DOBTo *time.Time // born before
|
||||||
|
Sex *entity.Sex
|
||||||
|
|
||||||
|
City *int64
|
||||||
|
Province *int64
|
||||||
|
Country *int64
|
||||||
|
|
||||||
|
Search *string
|
||||||
|
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
type PatientMapFilter struct {
|
||||||
|
MinDOB *time.Time
|
||||||
|
MaxDOB *time.Time
|
||||||
|
Sex *entity.Sex
|
||||||
|
Search *string
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
package analytic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
analytic2 "git.gocasts.ir/ebhomengo/niki/patientapp/delivery/http/analytic/dto"
|
||||||
|
"git.gocasts.ir/ebhomengo/niki/patientapp/service/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidProvinceID = errors.New("invalid province id")
|
||||||
|
ErrInvalidCountryID = errors.New("invalid country id")
|
||||||
|
ErrInvalidMapLevel = errors.New("invalid map level")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
GetPatients(ctx context.Context, f PatientFilter) ([]entity.Patient, error)
|
||||||
|
CountPatients(ctx context.Context, f PatientFilter) (int, error)
|
||||||
|
|
||||||
|
SummaryByCity(ctx context.Context, provinceID int, f PatientMapFilter) ([]entity.MapSummaryItem, error)
|
||||||
|
SummaryByProvince(ctx context.Context, countryID int, f PatientMapFilter) ([]entity.MapSummaryItem, error)
|
||||||
|
SummaryByCountry(ctx context.Context, f PatientMapFilter) ([]entity.MapSummaryItem, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repository Repository
|
||||||
|
validator Validator
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPatientAnalyticService(repo Repository, validator Validator) Service {
|
||||||
|
return Service{
|
||||||
|
repository: repo,
|
||||||
|
validator: validator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Service) List(ctx context.Context, req analytic2.ListPatientAnalyticRequest) (analytic2.PatientAnalyticResponse, error) {
|
||||||
|
|
||||||
|
limit, offset := normalizeLimitOffset(req.Limit, req.Offset)
|
||||||
|
|
||||||
|
// convert age range
|
||||||
|
dobFrom, dobTo := ageRangeToDOB(req.MinAge, req.MaxAge, time.Now())
|
||||||
|
|
||||||
|
filter := PatientFilter{
|
||||||
|
DOBFrom: dobFrom,
|
||||||
|
DOBTo: dobTo,
|
||||||
|
Sex: req.Sex,
|
||||||
|
City: req.City,
|
||||||
|
Province: req.Province,
|
||||||
|
Search: req.Search,
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := s.repository.GetPatients(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
return analytic2.PatientAnalyticResponse{}, fmt.Errorf("GetPatients: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
total, err := s.repository.CountPatients(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
return analytic2.PatientAnalyticResponse{}, fmt.Errorf("CountPatients: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapping response
|
||||||
|
out := make([]analytic2.PatientAnalyticItem, 0, len(items))
|
||||||
|
for _, value := range items {
|
||||||
|
out = append(out, analytic2.ToPatientResponse(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
return analytic2.PatientAnalyticResponse{
|
||||||
|
Items: out,
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
Total: total,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Service) GetMapSummary(ctx context.Context, req analytic2.GetPatientMapSummaryRequest) (analytic2.GetPatientMapSummaryResponse, error) {
|
||||||
|
|
||||||
|
dobFrom, dobTo := ageRangeToDOB(req.MinAge, req.MaxAge, time.Now())
|
||||||
|
|
||||||
|
filter := PatientMapFilter{
|
||||||
|
MinDOB: dobFrom,
|
||||||
|
MaxDOB: dobTo,
|
||||||
|
Sex: req.Sex,
|
||||||
|
Search: req.Search,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.Level {
|
||||||
|
case entity.MapLevelCity:
|
||||||
|
if req.ParentID == nil || *req.ParentID <= 0 {
|
||||||
|
return analytic2.GetPatientMapSummaryResponse{}, ErrInvalidProvinceID
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := s.repository.SummaryByCity(ctx, *req.ParentID, filter)
|
||||||
|
if err != nil {
|
||||||
|
return analytic2.GetPatientMapSummaryResponse{}, fmt.Errorf("SummaryByCity: %w", err)
|
||||||
|
}
|
||||||
|
return analytic2.GetPatientMapSummaryResponse{Level: req.Level, Items: items}, nil
|
||||||
|
|
||||||
|
case entity.MapLevelProvince:
|
||||||
|
if req.ParentID == nil || *req.ParentID <= 0 {
|
||||||
|
return analytic2.GetPatientMapSummaryResponse{}, ErrInvalidCountryID
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := s.repository.SummaryByProvince(ctx, *req.ParentID, filter)
|
||||||
|
if err != nil {
|
||||||
|
return analytic2.GetPatientMapSummaryResponse{}, fmt.Errorf("SummaryByProvince: %w", err)
|
||||||
|
}
|
||||||
|
return analytic2.GetPatientMapSummaryResponse{Level: req.Level, Items: items}, nil
|
||||||
|
|
||||||
|
case entity.MapLevelCountry:
|
||||||
|
items, err := s.repository.SummaryByCountry(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
return analytic2.GetPatientMapSummaryResponse{}, fmt.Errorf("SummaryByCountry: %w", err)
|
||||||
|
}
|
||||||
|
return analytic2.GetPatientMapSummaryResponse{Level: req.Level, Items: items}, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return analytic2.GetPatientMapSummaryResponse{}, ErrInvalidMapLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
package analytic
|
||||||
|
|
||||||
|
type Validator struct {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package entity
|
||||||
|
|
||||||
|
type Address struct {
|
||||||
|
ID uint
|
||||||
|
PostalCode string
|
||||||
|
Address string
|
||||||
|
Name string
|
||||||
|
Lat float64
|
||||||
|
Lon float64
|
||||||
|
CityID uint
|
||||||
|
ProvinceID uint
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddressAggregated struct {
|
||||||
|
Address Address
|
||||||
|
Province Province
|
||||||
|
City City
|
||||||
|
}
|
||||||
|
|
||||||
|
type Province struct {
|
||||||
|
ID uint
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
type City struct {
|
||||||
|
ID uint
|
||||||
|
Name string
|
||||||
|
ProvinceID uint
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package entity
|
||||||
|
|
||||||
|
type MapLevel string
|
||||||
|
|
||||||
|
const (
|
||||||
|
MapLevelCity MapLevel = "city"
|
||||||
|
MapLevelProvince MapLevel = "province"
|
||||||
|
MapLevelCountry MapLevel = "country"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MapSummaryItem struct {
|
||||||
|
LocationID int64
|
||||||
|
Name string
|
||||||
|
Count int
|
||||||
|
CentroidLat float64
|
||||||
|
CentroidLng float64
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
package entity
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Patient struct {
|
||||||
|
ID int64
|
||||||
|
FirstName string
|
||||||
|
LastName string
|
||||||
|
DateOfBirth *time.Time
|
||||||
|
Sex Sex
|
||||||
|
Phone string
|
||||||
|
Address Address
|
||||||
|
CaseStatus CaseStatus
|
||||||
|
ReferralSource ReferralSource
|
||||||
|
AssignedStaffId int64
|
||||||
|
StartDate string
|
||||||
|
EndDate string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sex ================================== Sex type ==========================================
|
||||||
|
type Sex string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SexUnknown Sex = "unknown"
|
||||||
|
SexMale Sex = "male"
|
||||||
|
SexFemale Sex = "female"
|
||||||
|
SexOther Sex = "other"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s Sex) SexValidation() bool {
|
||||||
|
switch s {
|
||||||
|
case SexUnknown, SexMale, SexFemale, SexOther:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaseStatus =================================== Case Status =======================================
|
||||||
|
type CaseStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Open CaseStatus = "open"
|
||||||
|
Close CaseStatus = "close"
|
||||||
|
InProgress CaseStatus = "inProgress"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s CaseStatus) CaseStatusValidation() bool {
|
||||||
|
switch s {
|
||||||
|
case Open, Close, InProgress:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReferralSource =================================== Referral source =======================================
|
||||||
|
type ReferralSource string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Hospital ReferralSource = "hospital"
|
||||||
|
Community ReferralSource = "community"
|
||||||
|
Other ReferralSource = "other"
|
||||||
|
)
|
||||||
Binary file not shown.
Loading…
Reference in New Issue