add echo web framework to patientapp domain

This commit is contained in:
Mohammad Amin 2026-04-02 18:29:19 +03:30
parent 1403e2c927
commit 3259a7468e
32 changed files with 3637 additions and 613 deletions

View File

@ -18,8 +18,8 @@ type TestContainer struct {
dockerPool *dockertest.Pool // the connection pool to Docker. dockerPool *dockertest.Pool // the connection pool to Docker.
mariaResource *dockertest.Resource // MariaDB Docker container resource. mariaResource *dockertest.Resource // MariaDB Docker container resource.
redisResource *dockertest.Resource // Redis Docker container resource. redisResource *dockertest.Resource // Redis Docker container resource.
mariaDBConn *mysql.DB // Connection to the MariaDB database. mariaDBConn *mysql.DB // Connection to the MariaDB mysql.
redisDBConn *redisadapter.Adapter // Connection to the Redis database. redisDBConn *redisadapter.Adapter // Connection to the Redis mysql.
containerExpiryInSeconds uint containerExpiryInSeconds uint
} }
@ -158,7 +158,7 @@ func (t *TestContainer) Start() {
return nil return nil
}); err != nil { }); err != nil {
log.Fatalf("Could not connect to database: %s", err) log.Fatalf("Could not connect to mysql: %s", err)
} }
} }

View File

@ -43,7 +43,7 @@ func Config() config.Config {
} }
func MariaDB(cfg config.Config) *mysql.DB { func MariaDB(cfg config.Config) *mysql.DB {
migrate := flag.Bool("migrate", false, "perform database migration") migrate := flag.Bool("migrate", false, "perform mysql migration")
flag.Parse() flag.Parse()
if *migrate { if *migrate {
migrator.New(migrator.Config{ migrator.New(migrator.Config{

View File

@ -1,34 +1,37 @@
package patientapp package patientapp
import ( import (
"net/http" "git.gocasts.ir/ebhomengo/niki/patientapp/config"
"git.gocasts.ir/ebhomengo/niki/patientapp/delivery/http/analytic" "git.gocasts.ir/ebhomengo/niki/patientapp/delivery/http/analytic"
"git.gocasts.ir/ebhomengo/niki/patientapp/repository/database" "git.gocasts.ir/ebhomengo/niki/patientapp/repository/mysql"
"github.com/labstack/echo/v4"
) )
type Application struct { type Application struct {
Config Config //Config Config
HTTPServer *http.Server HTTPServer *config.EchoServer
DB *database.DataBase DB *mysql.DataBase
Router http.Handler
} }
func Setup(config Config, server *http.Server, conn *database.DataBase) Application { func Setup(cfg config.Config, conn *mysql.DataBase) Application {
handler := analytic.NewHandler()
router := analytic.NewRouter(handler) e := echo.New()
server := config.EchoServer{
Router: e,
Config: cfg,
}
return Application{ return Application{
Config: config, //Config: config,
HTTPServer: server, HTTPServer: &server,
DB: conn, DB: conn,
Router: router,
} }
} }
func (a *Application) Start() { func (a Application) Start() {
srv := &http.Server{Addr: a.HTTPServer.Addr}
server := analytic.NewServer(srv, a.Router) server := analytic.NewServer(a.HTTPServer)
_ = server.Serve() _ = server.Serve()
} }

View File

@ -1,4 +0,0 @@
package patientapp
type Config struct {
}

View File

@ -0,0 +1,22 @@
package config
import (
"time"
"github.com/labstack/echo/v4"
)
type Config struct {
Port int `koanf:"port"`
Cors Cors `koanf:"cors"`
ShutDownCtxTimeout time.Duration `koanf:"shutdown_context_timeout"`
}
type Cors struct {
AllowOrigins []string `koanf:"allow_origins"`
}
type EchoServer struct {
Router *echo.Echo
Config Config
}

View File

@ -1,13 +1,11 @@
package analytic package analytic
import ( import (
"fmt"
"log"
"net/http" "net/http"
"strings"
"git.gocasts.ir/ebhomengo/niki/patientapp/delivery/http/analytic/dto"
svc "git.gocasts.ir/ebhomengo/niki/patientapp/service/analytic" svc "git.gocasts.ir/ebhomengo/niki/patientapp/service/analytic"
richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error"
"github.com/labstack/echo/v4"
) )
type Handler struct { type Handler struct {
@ -20,136 +18,48 @@ func NewHandler(service svc.Service) *Handler {
} }
} }
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { func (h *Handler) Health(e echo.Context) error {
w.WriteHeader(http.StatusOK) return e.JSON(http.StatusOK, map[string]interface{}{"status": "ok"})
_, _ = w.Write([]byte("ok"))
} }
func (h *Handler) PatientsAnalytic(w http.ResponseWriter, r *http.Request) { func (h *Handler) PatientsAnalytic(e echo.Context) error {
q := r.URL.Query() var req svc.ListPatientAnalyticRequest
minAge, err := parseIntPtr(q, "minAge") richErr := richerror.New(richerror.Op("fetchingPatientList.PatientsAnalytic"))
if err := e.Bind(&req); err != nil {
richErr = richErr.WithErr(err)
richErr = richErr.WithKind(1)
return echo.NewHTTPError(http.StatusBadRequest, richErr)
}
response, err := h.service.List(e.Request().Context(), req)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid minAge") richErr = richErr.WithErr(err)
return richErr = richErr.WithKind(4)
} return echo.NewHTTPError(http.StatusBadRequest, richErr)
maxAge, err := parseIntPtr(q, "maxAge")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid maxAge")
return
} }
sex, err := parseSexPtr(q, "sex") return e.JSON(http.StatusOK, response)
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) { func (h *Handler) PatientsMapSummary(e echo.Context) error {
q := r.URL.Query() richErr := richerror.New(richerror.Op("fetchingPatientMapSummary.PatientsMapSummary"))
level, err := parseMapLevel(q, "level") var req svc.GetPatientMapSummaryRequest
if err != nil {
writeError(w, http.StatusBadRequest, "invalid level (city|province|country)") if err := e.Bind(&req); err != nil {
return richErr = richErr.WithErr(err)
richErr = richErr.WithKind(1)
return echo.NewHTTPError(http.StatusBadRequest, richErr)
} }
parentID, err := parseIntPtr(q, "parentId") resp, svcErr := h.service.GetMapSummary(e.Request().Context(), req)
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 { if svcErr != nil {
log.Println("GetMapSummary error:", svcErr) richErr = richErr.WithErr(svcErr)
writeError(w, http.StatusInternalServerError, svcErr.Error()) richErr = richErr.WithKind(4)
return return echo.NewHTTPError(http.StatusBadRequest, richErr)
} }
writeJSON(w, http.StatusOK, resp) return e.JSON(http.StatusOK, resp)
} }

View File

@ -1,70 +0,0 @@
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
}
}

View File

@ -1,17 +1,21 @@
package analytic package analytic
import "net/http" import (
"git.gocasts.ir/ebhomengo/niki/patientapp/repository/mysql"
analytic2 "git.gocasts.ir/ebhomengo/niki/patientapp/service/analytic"
"github.com/labstack/echo/v4"
)
func NewRouter(h *Handler) http.Handler { func NewPatientAnalyticRouter(s *echo.Group) {
mux := http.NewServeMux()
// Define Routes repo := mysql.NewPatientRepo()
v1 := http.NewServeMux() validator := analytic2.NewValidator()
v1.HandleFunc("GET /health", h.Health) analyticService := analytic2.NewPatientAnalyticService(repo, *validator)
v1.HandleFunc("GET /patients", h.PatientsAnalytic)
v1.HandleFunc("GET /patients-summary", h.PatientsMapSummary)
mux.Handle("/v1/", http.StripPrefix("/v1", v1)) h := NewHandler(analyticService)
s.GET("/patients", h.PatientsAnalytic)
s.GET("/patients-summary", h.PatientsMapSummary)
s.GET("/health", h.Health)
return mux
} }

View File

@ -1,23 +1,41 @@
package analytic package analytic
import ( import (
"context"
"fmt" "fmt"
"net/http"
"git.gocasts.ir/ebhomengo/niki/patientapp/config"
) )
type Server struct { type Server struct {
HTTPServer *http.Server HTTPServer *config.EchoServer
} }
func NewServer(server *http.Server, router http.Handler) *Server { func NewServer(server *config.EchoServer) *Server {
server.Handler = router
return &Server{ return &Server{
HTTPServer: server, HTTPServer: server,
} }
} }
func (s Server) Serve() error { func (s Server) Serve() error {
s.RegisterRoutes()
// Start server // Start server
fmt.Printf("start sever on %s \n", s.HTTPServer.Addr) return s.HTTPServer.Router.Start(fmt.Sprintf(":%d", s.HTTPServer.Config.Port))
return s.HTTPServer.ListenAndServe() }
func (s Server) Stop(ctx context.Context) error {
return s.HTTPServer.Router.Shutdown(ctx)
}
func (s Server) RegisterRoutes() {
v1 := s.HTTPServer.Router.Group("/v1")
{
// Analytic Group
analyticGroup := v1.Group("/analytic")
NewPatientAnalyticRouter(analyticGroup)
}
} }

View File

@ -1,303 +0,0 @@
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()
}

View File

@ -1,33 +0,0 @@
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
}

View File

@ -0,0 +1,349 @@
package mysql
import (
"context"
"log"
"git.gocasts.ir/ebhomengo/niki/patientapp/service/analytic"
"git.gocasts.ir/ebhomengo/niki/patientapp/service/entity"
)
type DataBase struct {
//conn *mysql.DB
patients []entity.Patient
}
func NewPatientRepo( /*conn *mysql.DB*/) *DataBase {
patients, err := LoadPatientsFromJSON("C:\\Users\\cafel\\Documents\\gocast\\eb\\patientapp\\repository\\mysql\\rawdata.json")
if err != nil {
log.Fatal(err)
return nil
}
return &DataBase{
//conn: conn,
patients: patients,
}
}
func (db *DataBase) GetPatients(ctx context.Context, f analytic.PatientFilter) ([]entity.Patient, error) {
return db.patients, nil
}
func (db *DataBase) CountPatients(ctx context.Context, f analytic.PatientFilter) (int, error) {
return len(db.patients), nil
}
func (db *DataBase) SummaryByCity(ctx context.Context, provinceID uint, f analytic.PatientMapFilter) ([]entity.MapSummaryItem, error) {
var out []entity.MapSummaryItem
for _, patient := range db.patients {
if patient.Address.ProvinceID == provinceID {
out = append(out, entity.MapSummaryItem{
LocationID: int64(patient.Address.ID),
Name: patient.Address.Name,
CentroidLat: patient.Address.Lat,
CentroidLng: patient.Address.Lon,
})
}
}
return out, nil
}
func (db *DataBase) SummaryByProvince(ctx context.Context, f analytic.PatientMapFilter) (map[uint][]entity.MapSummaryItem, error) {
result := make(map[uint][]entity.MapSummaryItem, 0)
for _, patient := range db.patients {
out := entity.MapSummaryItem{
LocationID: int64(patient.Address.ID),
Name: patient.Address.Name,
CentroidLat: patient.Address.Lat,
CentroidLng: patient.Address.Lon,
}
result[patient.Address.ProvinceID] = append(result[patient.Address.ProvinceID], out)
}
return result, nil
}
//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()
//}

View File

@ -7,4 +7,4 @@ postgres_db:
db_name: patient_db db_name: patient_db
ssl_mode: disable ssl_mode: disable
path_of_migration: ./patientapp/repository/database/migrations path_of_migration: ./patientapp/repository/mysql/migrations

View File

@ -0,0 +1,56 @@
package mysql
import (
"encoding/json"
"fmt"
"os"
"strings"
"git.gocasts.ir/ebhomengo/niki/patientapp/service/entity"
)
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
}
func LoadPatientsFromJSON(filePath string) ([]entity.Patient, error) {
// Read the file
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
// Unmarshal into slice of Patient
var patients []entity.Patient
if err := json.Unmarshal(data, &patients); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
}
return patients, nil
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,10 @@
package analytic package analytic
import "time" import (
"fmt"
"github.com/jalaali/go-jalaali"
"time"
)
func normalizeLimitOffset(limit, offset int) (int, int) { func normalizeLimitOffset(limit, offset int) (int, int) {
if limit <= 0 { if limit <= 0 {
@ -16,17 +20,24 @@ func normalizeLimitOffset(limit, offset int) (int, int) {
} }
// convert age range -> DOB range // convert age range -> DOB range
func ageRangeToDOB(minAge, maxAge *int, now time.Time) (dobFrom, dobTo *time.Time) { func ageRangeToDOB(minAge, maxAge *int, now time.Time) (dobFrom, dobTo *string) {
// maxAge => DOB >= now-(maxAge+1y)+1day
// eg: now is : 3/31/2026; maxAge=30 => (now - 31)= 3/31/1994 => +=1
if maxAge != nil { if maxAge != nil {
t := now.AddDate(-*maxAge-1, 0, 0).Add(24 * time.Hour) t := now.AddDate(-(*maxAge + 1), 0, 1)
dobFrom = &t jy, jm, jd, err := jalaali.ToJalaali(t.Year(), t.Month(), t.Day())
if err != nil {
} }
// minAge => DOB <= now-minAge s := fmt.Sprintf("%04d/%02d/%02d", jy, jm, jd)
dobFrom = &s
}
if minAge != nil { if minAge != nil {
t := now.AddDate(-*minAge, 0, 0) t := now.AddDate(-*minAge, 0, 0)
dobTo = &t jy, jm, jd, err := jalaali.ToJalaali(t.Year(), t.Month(), t.Day())
if err != nil {
} }
s := fmt.Sprintf("%04d/%02d/%02d", jy, jm, jd)
dobTo = &s
}
return return
} }

View File

@ -1,8 +1,6 @@
package dto package analytic
import ( import (
"time"
"git.gocasts.ir/ebhomengo/niki/patientapp/service/entity" "git.gocasts.ir/ebhomengo/niki/patientapp/service/entity"
) )
@ -25,7 +23,7 @@ type PatientAnalyticItem struct {
ID int64 `json:"id"` ID int64 `json:"id"`
FirstName string `json:"first_name"` FirstName string `json:"first_name"`
LastName string `json:"Last_name"` LastName string `json:"Last_name"`
DateOfBirth *time.Time `json:"dob,omitempty"` DateOfBirth string `json:"dob,omitempty"`
Sex entity.Sex `json:"sex"` Sex entity.Sex `json:"sex"`
Phone string `json:"phone"` Phone string `json:"phone"`
Address entity.Address `json:"address"` Address entity.Address `json:"address"`

View File

@ -1,14 +1,12 @@
package analytic package analytic
import ( import (
"time"
"git.gocasts.ir/ebhomengo/niki/patientapp/service/entity" "git.gocasts.ir/ebhomengo/niki/patientapp/service/entity"
) )
type PatientFilter struct { type PatientFilter struct {
DOBFrom *time.Time // born after DOBFrom *string // born after
DOBTo *time.Time // born before DOBTo *string // born before
Sex *entity.Sex Sex *entity.Sex
City *int64 City *int64
@ -22,8 +20,8 @@ type PatientFilter struct {
} }
type PatientMapFilter struct { type PatientMapFilter struct {
MinDOB *time.Time MinDOB *string
MaxDOB *time.Time MaxDOB *string
Sex *entity.Sex Sex *entity.Sex
Search *string Search *string
} }

View File

@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"time" "time"
analytic2 "git.gocasts.ir/ebhomengo/niki/patientapp/delivery/http/analytic/dto"
"git.gocasts.ir/ebhomengo/niki/patientapp/service/entity" "git.gocasts.ir/ebhomengo/niki/patientapp/service/entity"
) )
@ -22,7 +21,6 @@ type Repository interface {
SummaryByCity(ctx context.Context, provinceID int, f PatientMapFilter) ([]entity.MapSummaryItem, error) SummaryByCity(ctx context.Context, provinceID int, f PatientMapFilter) ([]entity.MapSummaryItem, error)
SummaryByProvince(ctx context.Context, countryID 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 { type Service struct {
@ -37,7 +35,7 @@ func NewPatientAnalyticService(repo Repository, validator Validator) Service {
} }
} }
func (s Service) List(ctx context.Context, req analytic2.ListPatientAnalyticRequest) (analytic2.PatientAnalyticResponse, error) { func (s Service) List(ctx context.Context, req ListPatientAnalyticRequest) (PatientAnalyticResponse, error) {
limit, offset := normalizeLimitOffset(req.Limit, req.Offset) limit, offset := normalizeLimitOffset(req.Limit, req.Offset)
@ -57,21 +55,21 @@ func (s Service) List(ctx context.Context, req analytic2.ListPatientAnalyticRequ
items, err := s.repository.GetPatients(ctx, filter) items, err := s.repository.GetPatients(ctx, filter)
if err != nil { if err != nil {
return analytic2.PatientAnalyticResponse{}, fmt.Errorf("GetPatients: %w", err) return PatientAnalyticResponse{}, fmt.Errorf("GetPatients: %w", err)
} }
total, err := s.repository.CountPatients(ctx, filter) total, err := s.repository.CountPatients(ctx, filter)
if err != nil { if err != nil {
return analytic2.PatientAnalyticResponse{}, fmt.Errorf("CountPatients: %w", err) return PatientAnalyticResponse{}, fmt.Errorf("CountPatients: %w", err)
} }
// mapping response // mapping response
out := make([]analytic2.PatientAnalyticItem, 0, len(items)) out := make([]PatientAnalyticItem, 0, len(items))
for _, value := range items { for _, value := range items {
out = append(out, analytic2.ToPatientResponse(value)) out = append(out, ToPatientResponse(value))
} }
return analytic2.PatientAnalyticResponse{ return PatientAnalyticResponse{
Items: out, Items: out,
Limit: limit, Limit: limit,
Offset: offset, Offset: offset,
@ -80,7 +78,7 @@ func (s Service) List(ctx context.Context, req analytic2.ListPatientAnalyticRequ
} }
func (s Service) GetMapSummary(ctx context.Context, req analytic2.GetPatientMapSummaryRequest) (analytic2.GetPatientMapSummaryResponse, error) { func (s Service) GetMapSummary(ctx context.Context, req GetPatientMapSummaryRequest) (GetPatientMapSummaryResponse, error) {
dobFrom, dobTo := ageRangeToDOB(req.MinAge, req.MaxAge, time.Now()) dobFrom, dobTo := ageRangeToDOB(req.MinAge, req.MaxAge, time.Now())
@ -94,35 +92,28 @@ func (s Service) GetMapSummary(ctx context.Context, req analytic2.GetPatientMapS
switch req.Level { switch req.Level {
case entity.MapLevelCity: case entity.MapLevelCity:
if req.ParentID == nil || *req.ParentID <= 0 { if req.ParentID == nil || *req.ParentID <= 0 {
return analytic2.GetPatientMapSummaryResponse{}, ErrInvalidProvinceID return GetPatientMapSummaryResponse{}, ErrInvalidProvinceID
} }
items, err := s.repository.SummaryByCity(ctx, *req.ParentID, filter) items, err := s.repository.SummaryByCity(ctx, *req.ParentID, filter)
if err != nil { if err != nil {
return analytic2.GetPatientMapSummaryResponse{}, fmt.Errorf("SummaryByCity: %w", err) return GetPatientMapSummaryResponse{}, fmt.Errorf("SummaryByCity: %w", err)
} }
return analytic2.GetPatientMapSummaryResponse{Level: req.Level, Items: items}, nil return GetPatientMapSummaryResponse{Level: req.Level, Items: items}, nil
case entity.MapLevelProvince: case entity.MapLevelProvince:
if req.ParentID == nil || *req.ParentID <= 0 { if req.ParentID == nil || *req.ParentID <= 0 {
return analytic2.GetPatientMapSummaryResponse{}, ErrInvalidCountryID return GetPatientMapSummaryResponse{}, ErrInvalidCountryID
} }
items, err := s.repository.SummaryByProvince(ctx, *req.ParentID, filter) items, err := s.repository.SummaryByProvince(ctx, *req.ParentID, filter)
if err != nil { if err != nil {
return analytic2.GetPatientMapSummaryResponse{}, fmt.Errorf("SummaryByProvince: %w", err) return GetPatientMapSummaryResponse{}, fmt.Errorf("SummaryByProvince: %w", err)
} }
return analytic2.GetPatientMapSummaryResponse{Level: req.Level, Items: items}, nil return 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: default:
return analytic2.GetPatientMapSummaryResponse{}, ErrInvalidMapLevel return GetPatientMapSummaryResponse{}, ErrInvalidMapLevel
} }
} }

View File

@ -2,3 +2,7 @@ package analytic
type Validator struct { type Validator struct {
} }
func NewValidator() *Validator {
return &Validator{}
}

View File

@ -1,12 +1,10 @@
package entity package entity
import "time"
type Patient struct { type Patient struct {
ID int64 ID int64
FirstName string FirstName string
LastName string LastName string
DateOfBirth *time.Time DateOfBirth string
Sex Sex Sex Sex
Phone string Phone string
Address Address Address Address

Binary file not shown.

View File

@ -0,0 +1,19 @@
package date_parser
import (
"fmt"
"time"
)
// ParseDate parses a date string in "YYYY-MM-DD" format and returns a time.Time object
func ParseDate(input string) (time.Time, error) {
const layout = "2006-01-02"
// Parse the input string
convertedDate, err := time.Parse(layout, input)
if err != nil {
return time.Time{}, fmt.Errorf("invalid date format: %v", err)
}
return convertedDate, nil
}

21
vendor/github.com/jalaali/go-jalaali/LICENSE generated vendored Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Amir Khazaie
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

23
vendor/github.com/jalaali/go-jalaali/README.md generated vendored Normal file
View File

@ -0,0 +1,23 @@
# Jalaali
Golang implementation of [Jalaali JS](https://github.com/jalaali/jalaali-js) and [Jalaali Python](https://github.com/jalaali/jalaali-python) implementations of Jalaali (Jalali, Persian, Khayyami, Khorshidi, Shamsi) convertion to Gregorian calendar system and vice-versa.
This implementation is based on an [algorithm by Kazimierz M. Borkowski](http://www.astro.uni.torun.pl/~kb/Papers/EMP/PersianC-EMP.htm). Borkowski claims that this algorithm works correctly for 3000 years!
Documentation on API is available [here](https://pkg.go.dev/github.com/jalaali/go-jalaali) at Go official documentation site.
## Installation
Use `go get` on this repository:
```sh
$ go get -u github.com/jalaali/go-jalaali
```
## Usage
* Wrapper around Golang [time package](https://golang.org/pkg/time):
* Call `Jalaali.Now()` to get instance of current time. You can use all function from `time` package with this wrapper.
* Call `Jalaali.From(t)` and pass a `time` instance to it. The you can work with it the same way you work with `time` package.
* Jalaali Formatting:
* Call `JFormat` method of a Jalaali instance and pass it the same formatting options that is used for Golang `time` package. The output will be in Jalaali date and use persian digits and words.

147
vendor/github.com/jalaali/go-jalaali/convertion.go generated vendored Normal file
View File

@ -0,0 +1,147 @@
package jalaali
import (
"fmt"
"time"
)
var (
breaks = [...]int{-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210,
1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178}
)
// ToJalaali converts Gregorian to Jalaali date. Error is not nil if Jalaali
// year passed to function is not valid.
func ToJalaali(gregorianYear int, gregorianMonth time.Month, gregorianDay int) (int, Month, int, error) {
jy, jm, jd, err := d2j(g2d(gregorianYear, int(gregorianMonth), gregorianDay))
return jy, Month(jm), jd, err
}
// ToGregorian converts Jalaali to Gregorian date. Error is not nil if Jalaali
// year passed to function is not valid.
func ToGregorian(jalaaliYear int, jalaaliMonth Month, jalaaliDay int) (int, time.Month, int, error) {
// validate the jalaali date using the utility function
if !IsValidDate(jalaaliYear, int(jalaaliMonth), jalaaliDay) {
return 0, 0, 0, fmt.Errorf("invalid jalaali date: year=%d, month=%d, day=%d", jalaaliYear, jalaaliMonth, jalaaliDay)
}
jdn, err := j2d(jalaaliYear, int(jalaaliMonth), jalaaliDay)
if err != nil {
return 0, 0, 0, err
}
gy, gm, gd := d2g(jdn)
return gy, time.Month(gm), gd, nil
}
func j2d(jy, jm, jd int) (jdn int, err error) {
_, gy, march, err := jalCal(jy)
if err != nil {
return 0, err
}
return g2d(gy, 3, march) + (jm-1)*31 - div(jm, 7)*(jm-7) + jd - 1, nil
}
func d2j(jdn int) (int, int, int, error) {
gy, _, _ := d2g(jdn) // Calculate Gregorian year (gy).
jy := gy - 621
leap, _, march, err := jalCal(jy)
jdn1f := g2d(gy, 3, march)
if err != nil {
return 0, 0, 0, err
}
// Find number of days that passed since 1 Farvardin.
k := jdn - jdn1f
if k >= 0 {
if k <= 185 {
// The first 6 months.
jm := 1 + div(k, 31)
jd := mod(k, 31) + 1
return jy, jm, jd, nil
}
// The remaining months.
k -= 186
} else {
// Previous Jalaali year.
jy--
k += 179
if leap == 1 {
k++
}
}
jm := 7 + div(k, 30)
jd := mod(k, 30) + 1
return jy, jm, jd, nil
}
func jalCal(jy int) (int, int, int, error) {
bl, gy, leapJ, jp := len(breaks), jy+621, -14, breaks[0]
jump := 0
if jy < jp || jy >= breaks[bl-1] {
return 0, 0, 0, &ErrorInvalidYear{jy}
}
// Find the limiting years for the Jalaali year jy.
for i := 1; i < bl; i++ {
jm := breaks[i]
jump = jm - jp
if jy < jm {
break
}
leapJ += div(jump, 33)*8 + div(mod(jump, 33), 4)
jp = jm
}
n := jy - jp
// Find the number of leap years from AD 621 to the beginning
// of the current Jalaali year in the Persian calendar.
leapJ += div(n, 33)*8 + div(mod(n, 33)+3, 4)
if mod(jump, 33) == 4 && jump-n == 4 {
leapJ++
}
// And the same in the Gregorian calendar (until the year gy).
leapG := div(gy, 4) - div((div(gy, 100)+1)*3, 4) - 150
// Determine the Gregorian date of Farvardin the 1st.
march := 20 + leapJ - leapG
// Find how many years have passed since the last leap year.
if jump-n < 6 {
n -= jump + div(jump+4, 33)*33
}
leap := mod(mod(n+1, 33)-1, 4)
if leap == -1 {
leap = 4
}
return leap, gy, march, nil
}
func g2d(gy, gm, gd int) int {
d := div((gy+div(gm-8, 6)+100100)*1461, 4) +
div(153*mod(gm+9, 12)+2, 5) +
gd - 34840408
d = d - div(div(gy+100100+div(gm-8, 6), 100)*3, 4) + 752
return d
}
func d2g(jdn int) (int, int, int) {
j := 4*jdn + 139361631
j = j + div(div(4*jdn+183187720, 146097)*3, 4)*4 - 3908
i := div(mod(j, 1461), 4)*5 + 308
gd := div(mod(i, 153), 5) + 1
gm := mod(div(i, 153), 12) + 1
gy := div(j, 1461) - 100100 + div(8-gm, 6)
return gy, gm, gd
}
func div(a, b int) int {
return a / b
}
func mod(a, b int) int {
return a % b
}

19
vendor/github.com/jalaali/go-jalaali/errors.go generated vendored Normal file
View File

@ -0,0 +1,19 @@
package jalaali
import "fmt"
// ErrorNilReference is happening when a pointer is nil.
type ErrorNilReference struct{}
// ErrorInvalidYear is happening when year passed is is in proper range.
type ErrorInvalidYear struct {
year int
}
func (e *ErrorNilReference) Error() string {
return "jalaali: reference is nil"
}
func (e *ErrorInvalidYear) Error() string {
return fmt.Sprintf("jalaali: %v is invalid year", e.year)
}

318
vendor/github.com/jalaali/go-jalaali/format.go generated vendored Normal file
View File

@ -0,0 +1,318 @@
package jalaali
const (
_ = iota
stdLongMonth = iota + stdNeedDate // "January"
stdMonth // "Jan"
stdNumMonth // "1"
stdZeroMonth // "01"
stdLongWeekDay // "Monday"
stdWeekDay // "Mon"
stdDay // "2"
stdUnderDay // "_2"
stdZeroDay // "02"
stdHour = iota + stdNeedClock // "15"
stdHour12 // "3"
stdZeroHour12 // "03"
stdMinute // "4"
stdZeroMinute // "04"
stdSecond // "5"
stdZeroSecond // "05"
stdLongYear = iota + stdNeedDate // "2006"
stdYear // "06"
stdPM = iota + stdNeedClock // "PM"
stdpm // "pm"
stdFracSecond0 // ".0", ".00", ... , trailing zeros included
stdFracSecond9 // ".9", ".99", ..., trailing zeros omitted
stdNeedDate = 1 << 8 // need month, day, year
stdNeedClock = 2 << 8 // need hour, minute, second
stdArgShift = 16 // extra argument in high bits, above low stdArgShift
stdMask = 1<<stdArgShift - 1 // mask out argument
)
// std0x records the std values for "01", "02", ..., "06".
var std0x = [...]int{stdZeroMonth, stdZeroDay, stdZeroHour12, stdZeroMinute, stdZeroSecond, stdYear}
// JFormat gets default Golang layout string and parse put Jalaali calender information
// into the final string and return it.
func (j Jalaali) JFormat(layout string) (string, error) {
const minBufSize = 64
bufSize := len(layout)
if bufSize < minBufSize { // minimum buffer size
bufSize = minBufSize
}
b := make([]byte, 0, len(layout))
b, err := j.jAppendFormat(b, layout)
return string(b), err
}
// jAppendFormat is like JFormat but appends the textual
// representation to b and returns the extended buffer.
func (j Jalaali) jAppendFormat(b []byte, layout string) ([]byte, error) {
var (
year int = -1
month Month
day int
hour int = -1
min int
sec int
)
// Each iteration generates one std value.
for layout != "" {
prefix, std, suffix := nextStdChunk(layout)
if prefix != "" {
b = append(b, prefix...)
}
if std == 0 {
break
}
layout = suffix
// Compute year, month, day if needed.
if year < 0 && std&stdNeedDate != 0 {
var err error
year, month, day, err = ToJalaali(j.Year(), j.Month(), j.Day())
if err != nil {
return b, err
}
}
// Compute hour, minute, second if needed.
if hour < 0 && std&stdNeedClock != 0 {
hour, min, sec = j.Hour(), j.Minute(), j.Second()
}
switch std & stdMask {
case stdYear:
y := year
if y < 0 {
y = -y
}
b = appendInt(b, y%100, 2)
case stdLongYear:
b = appendInt(b, year, 4)
case stdMonth, stdLongMonth:
m := month.String()
b = append(b, m...)
case stdNumMonth:
b = appendInt(b, int(month), 0)
case stdZeroMonth:
b = appendInt(b, int(month), 2)
case stdWeekDay, stdLongWeekDay:
s := Weekday((int(j.Weekday()) + 1) % 7).String()
b = append(b, s...)
case stdDay:
b = appendInt(b, day, 0)
case stdUnderDay:
if day < 10 {
b = append(b, ' ')
}
b = appendInt(b, day, 0)
case stdZeroDay:
b = appendInt(b, day, 2)
case stdHour:
b = appendInt(b, hour, 2)
case stdHour12:
// Noon is 12PM, midnight is 12AM.
hr := hour % 12
if hr == 0 {
hr = 12
}
b = appendInt(b, hr, 0)
case stdZeroHour12:
// Noon is 12PM, midnight is 12AM.
hr := hour % 12
if hr == 0 {
hr = 12
}
b = appendInt(b, hr, 2)
case stdMinute:
b = appendInt(b, min, 0)
case stdZeroMinute:
b = appendInt(b, min, 2)
case stdSecond:
b = appendInt(b, sec, 0)
case stdZeroSecond:
b = appendInt(b, sec, 2)
case stdPM, stdpm:
if hour >= 12 {
b = append(b, "بعدازظهر"...)
} else {
b = append(b, "قبل‌ازظهر"...)
}
case stdFracSecond0, stdFracSecond9:
b = formatNano(b, uint(j.Nanosecond()), std>>stdArgShift, std&stdMask == stdFracSecond9)
}
}
return b, nil
}
// nextStdChunk finds the first occurrence of a std string in
// layout and returns the text before, the std string, and the text after.
func nextStdChunk(layout string) (prefix string, std int, suffix string) {
for i := 0; i < len(layout); i++ {
switch c := int(layout[i]); c {
case 'J': // January, Jan
if len(layout) >= i+3 && layout[i:i+3] == "Jan" {
if len(layout) >= i+7 && layout[i:i+7] == "January" {
return layout[0:i], stdLongMonth, layout[i+7:]
}
if !startsWithLowerCase(layout[i+3:]) {
return layout[0:i], stdMonth, layout[i+3:]
}
}
case 'M': // Monday, Mon
if layout[i:i+3] == "Mon" {
if len(layout) >= i+6 && layout[i:i+6] == "Monday" {
return layout[0:i], stdLongWeekDay, layout[i+6:]
}
if !startsWithLowerCase(layout[i+3:]) {
return layout[0:i], stdWeekDay, layout[i+3:]
}
}
case '0': // 01, 02, 03, 04, 05, 06
if len(layout) >= i+2 && '1' <= layout[i+1] && layout[i+1] <= '6' {
return layout[0:i], std0x[layout[i+1]-'1'], layout[i+2:]
}
case '1': // 15, 1
if len(layout) >= i+2 && layout[i+1] == '5' {
return layout[0:i], stdHour, layout[i+2:]
}
return layout[0:i], stdNumMonth, layout[i+1:]
case '2': // 2006, 2
if len(layout) >= i+4 && layout[i:i+4] == "2006" {
return layout[0:i], stdLongYear, layout[i+4:]
}
return layout[0:i], stdDay, layout[i+1:]
case '_': // _2, _2006
if len(layout) >= i+2 && layout[i+1] == '2' {
//_2006 is really a literal _, followed by stdLongYear
if len(layout) >= i+5 && layout[i+1:i+5] == "2006" {
return layout[0 : i+1], stdLongYear, layout[i+5:]
}
return layout[0:i], stdUnderDay, layout[i+2:]
}
case '3':
return layout[0:i], stdHour12, layout[i+1:]
case '4':
return layout[0:i], stdMinute, layout[i+1:]
case '5':
return layout[0:i], stdSecond, layout[i+1:]
case 'P': // PM
if len(layout) >= i+2 && layout[i+1] == 'M' {
return layout[0:i], stdPM, layout[i+2:]
}
case 'p': // pm
if len(layout) >= i+2 && layout[i+1] == 'm' {
return layout[0:i], stdpm, layout[i+2:]
}
case '.': // .000 or .999 - repeated digits for fractional seconds.
if i+1 < len(layout) && (layout[i+1] == '0' || layout[i+1] == '9') {
ch := layout[i+1]
j := i + 1
for j < len(layout) && layout[j] == ch {
j++
}
// String of digits must end here - only fractional second is all digits.
if !isDigit(layout, j) {
std := stdFracSecond0
if layout[i+1] == '9' {
std = stdFracSecond9
}
std |= (j - (i + 1)) << stdArgShift
return layout[0:i], std, layout[j:]
}
}
}
}
return layout, 0, ""
}
// startsWithLowerCase reports whether the string has a lower-case letter at the beginning.
// Its purpose is to prevent matching strings like "Month" when looking for "Mon".
func startsWithLowerCase(str string) bool {
if len(str) == 0 {
return false
}
c := str[0]
return 'a' <= c && c <= 'z'
}
// isDigit reports whether s[i] is in range and is a decimal digit.
func isDigit(s string, i int) bool {
if len(s) <= i {
return false
}
c := s[i]
return '0' <= c && c <= '9'
}
// appendInt appends the decimal form of x to b and returns the result.
// If the decimal form (excluding sign) is shorter than width, the result is padded with leading 0's.
// Duplicates functionality in strconv, but avoids dependency.
func appendInt(b []byte, x int, width int) []byte {
u := uint(x)
if x < 0 {
b = append(b, '-')
u = uint(-x)
}
// Assemble decimal in reverse order.
var buf [20]rune
i := len(buf)
for u >= 10 {
i--
q := u / 10
buf[i] = rune('۰' + u - q*10)
u = q
}
i--
buf[i] = rune('۰' + u)
// Add 0-padding.
for w := len(buf) - i; w < width; w++ {
b = append(b, []byte("۰")...)
}
return append(b, []byte(string(buf[i:]))...)
}
// formatNano appends a fractional second, as nanoseconds, to b
// and returns the result.
func formatNano(b []byte, nanosec uint, n int, trim bool) []byte {
u := nanosec
var buf [9]rune
for start := len(buf); start > 0; {
start--
buf[start] = rune(u%10 + '۰')
u /= 10
}
if n > 9 {
n = 9
}
if trim {
for n > 0 && buf[n-1] == '۰' {
n--
}
if n == 0 {
return b
}
}
b = append(b, '.')
return append(b, []byte(string(buf[:n]))...)
}

3
vendor/github.com/jalaali/go-jalaali/go.mod generated vendored Normal file
View File

@ -0,0 +1,3 @@
module github.com/jalaali/go-jalaali
go 1.13

79
vendor/github.com/jalaali/go-jalaali/jalaali.go generated vendored Normal file
View File

@ -0,0 +1,79 @@
package jalaali
import (
"strconv"
"time"
)
// A simple wrapper around Golang default time package. You have all the functionality of
// default time package and functionalities needed for Jalaali calender.
type Jalaali struct {
time.Time
}
// From initialize new instance of Jalaali from a time instance.
func From(t time.Time) Jalaali {
return Jalaali{t}
}
// Now with return Jalaali instance of current time.
func Now() Jalaali {
return From(time.Now())
}
// A Month specifies a month of the year (Farvardin = 1, ...).
type Month int
const (
Farvardin Month = 1 + iota
Ordibehesht
Khordad
Tir
Mordad
Shahrivar
Mehr
Aban
Azar
Dey
Bahman
Esfand
)
var months = []string{
"فروردین", "اردیبهشت", "خرداد",
"تیر", "مرداد", "شهریور",
"مهر", "آبان", "آذر",
"دی", "بهمن", "اسفند",
}
func (m Month) String() string {
if Farvardin <= m && m <= Esfand {
return months[m-1]
}
return "%!Month(" + strconv.Itoa(int(m)) + ")"
}
// A Weekday specifies a day of the week (Shanbe = 0, ...).
type Weekday int
const (
Shanbe Weekday = iota
IekShanbe
DoShanbe
SeShanbe
ChaharShanbe
PanjShanbe
Jome
)
var days = []string{
"شنبه", "یک‌شنبه", "دوشنبه", "سه‌شنبه", "چهارشنبه", "پنج‌شنبه", "جمعه",
}
func (d Weekday) String() string {
if Shanbe <= d && d <= Jome {
return days[d]
}
return "%!Weekday(" + strconv.Itoa(int(d)) + ")"
}

188
vendor/github.com/jalaali/go-jalaali/jalaali_test.go generated vendored Normal file
View File

@ -0,0 +1,188 @@
package jalaali
import (
"testing"
"time"
)
func TestFromYMD(t *testing.T) {
tests := []struct {
gy, gm, gd, jy, jm, jd int
}{
{1981, 8, 17, 1360, 5, 26},
{2013, 1, 10, 1391, 10, 21},
{2014, 8, 4, 1393, 5, 13},
}
for _, test := range tests {
y, m, d, err := ToJalaali(test.gy, time.Month(test.gm), test.gd)
if err != nil {
t.Errorf("%v", err)
} else if y != test.jy || m != Month(test.jm) || d != test.jd {
t.Errorf("Expected %v/%v/%v got %v/%v%v.", test.jy, test.jm, test.jd, y, m, d)
}
}
}
func TestToGregorian(t *testing.T) {
tests := []struct {
jy, jm, jd, gy, gm, gd int
}{
{1360, 5, 26, 1981, 8, 17},
{1391, 10, 21, 2013, 1, 10},
{1393, 5, 13, 2014, 8, 4},
}
for _, test := range tests {
y, m, d, err := ToGregorian(test.jy, Month(test.jm), test.jd)
if err != nil {
t.Errorf("%v", err)
} else if y != test.gy || m != time.Month(test.gm) || d != test.gd {
t.Errorf("Expected %v/%v/%v got %v/%v%v.", test.gy, test.gm, test.gd, y, m, d)
}
}
}
func TestIsValidDate(t *testing.T) {
tests := []struct {
y, m, d int
ok bool
}{
{-62, 12, 29, false},
{-61, 1, 1, true},
{3178, 1, 1, false},
{3177, 12, 29, true},
{1393, 0, 1, false},
{1393, 13, 1, false},
{1393, 1, 0, false},
{1393, 1, 32, false},
{1393, 1, 31, true},
{1393, 11, 31, false},
{1393, 11, 30, true},
{1393, 12, 30, false},
{1393, 12, 29, true},
{1395, 12, 30, true},
}
for _, test := range tests {
valid := IsValidDate(test.y, test.m, test.d)
if valid != test.ok {
calculated, actual := "", " not"
if test.ok {
calculated, actual = " not", ""
}
t.Errorf("%v/%v/%v is%v valid date but considered%v valid.",
test.y, test.m, test.d, actual, calculated)
}
}
}
func TestIsLeapYear(t *testing.T) {
tests := []struct {
year int
leap bool
}{
{1393, false},
{1394, false},
{1395, true},
{1396, false},
}
for _, test := range tests {
leap, err := IsLeapYear(test.year)
if err != nil {
t.Errorf("%v", err)
} else if leap != test.leap {
calculated, actual := "", " not"
if leap {
calculated, actual = " not", ""
}
t.Errorf("%v is%v leap but considered%v leap.", test.year, actual, calculated)
}
}
}
func TestMonthLength(t *testing.T) {
tests := []struct {
y, m, ml int
}{
{1393, 1, 31},
{1393, 4, 31},
{1393, 6, 31},
{1393, 7, 30},
{1393, 10, 30},
{1393, 12, 29},
{1394, 12, 29},
{1395, 12, 30},
}
for _, test := range tests {
calculated, err := MonthLength(test.y, test.m)
if err != nil {
t.Errorf("%v", err)
} else if calculated != test.ml {
t.Errorf("Length of %v/%v month is %v but considered %v.",
test.y, test.m, test.ml, calculated)
}
}
}
func TestJFormat(t *testing.T) {
iran, _ := time.LoadLocation("Asia/Tehran")
tests := []struct {
time time.Time
format []string
result []string
}{
{
time.Date(2001, 1, 1, 1, 1, 1, 1, iran),
[]string{
"2006 06", // Year formatting
"January Jan 1 01", // Month formatting
"Monday Mon 2 _2 02", // Day formatting
"15 3 03 4 04 5 05 PM pm", // Hour, Minute, Second formatting
".0 .00 .000 .000000 .000000000 .9 .99 .999 .999999 .999999999", // Nanosecond formatting
},
[]string{
"۱۳۷۹ ۷۹", // Year formatting
"دی دی ۱۰ ۱۰", // Month formatting
"دوشنبه دوشنبه ۱۲ ۱۲ ۱۲", // Day formatting
"۰۱ ۱ ۰۱ ۱ ۰۱ ۱ ۰۱ قبل‌ازظهر قبل‌ازظهر", // Hour, Minute, Second formatting
".۰ .۰۰ .۰۰۰ .۰۰۰۰۰۰ .۰۰۰۰۰۰۰۰۱ .۰۰۰۰۰۰۰۰۱", // Nanosecond formatting
},
}, {
time.Date(2001, 2, 3, 15, 17, 1, 999999999, iran),
[]string{
"2006 06", // Year formatting
"January Jan 1 01", // Month formatting
"Monday Mon 2 _2 02", // Day formatting
"15 3 03 4 04 5 05 PM pm", // Hour, Minute, Second formatting
".0 .00 .000 .000000 .000000000 .9 .99 .999 .999999 .999999999", // Nanosecond formatting
},
[]string{
"۱۳۷۹ ۷۹", // Year formatting
"بهمن بهمن ۱۱ ۱۱", // Month formatting
"شنبه شنبه ۱۵ ۱۵ ۱۵", // Day formatting
"۱۵ ۳ ۰۳ ۱۷ ۱۷ ۱ ۰۱ بعدازظهر بعدازظهر", // Hour, Minute, Second formatting
".۹ .۹۹ .۹۹۹ .۹۹۹۹۹۹ .۹۹۹۹۹۹۹۹۹ .۹ .۹۹ .۹۹۹ .۹۹۹۹۹۹ .۹۹۹۹۹۹۹۹۹", // Nanosecond formatting
},
},
}
for i, test := range tests {
j := From(test.time)
for f := range test.format {
result, err := j.JFormat(test.format[f])
if err != nil {
t.Error(err)
}
if result != test.result[f] {
t.Error("Bad formatting for test as index: ", i, "\nWanted: ", test.result[f], "\nGot: ", result)
}
}
}
}

53
vendor/github.com/jalaali/go-jalaali/utils.go generated vendored Normal file
View File

@ -0,0 +1,53 @@
package jalaali
import "strings"
var enToFa = strings.NewReplacer(
"0", "۰",
"1", "۱",
"2", "۲",
"3", "۳",
"4", "۴",
"5", "۵",
"6", "۶",
"7", "۷",
"8", "۸",
"9", "۹",
)
// IsValidDate take Jalaali date and return true if it is valid,
// otherwise false.
func IsValidDate(jy, jm, jd int) bool {
d, err := MonthLength(jy, jm)
if err != nil {
return false
}
return -61 <= jy && jy <= 3177 &&
1 <= jm && jm <= 12 &&
1 <= jd && jd <= d
}
// MonthLength take Jalaali date and return length of that specific
// month. Error is not nil if Jalaali year passed to function is not valid.
func MonthLength(jy, jm int) (int, error) {
if jm <= 6 {
return 31, nil
} else if jm <= 11 {
return 30, nil
}
leap, err := IsLeapYear(jy)
if err != nil {
return 0, err
} else if leap {
return 30, nil
}
return 29, nil
}
// IsLeapYear take a Jalaali year and return true if it is leap year. Error
// is not nil if Jalaali year passed to function is not valid.
func IsLeapYear(jy int) (bool, error) {
leap, _, _, err := jalCal(jy)
return leap == 0, err
}