merge: integrate outbound push/pull 1c sync with web updates
Some checks failed
Deploy Dana Medical Site / deploy (push) Failing after 1s

This commit is contained in:
Dana Medical Developer 2026-10-21 06:39:24 +00:00
commit 33b0b89271
6 changed files with 419 additions and 46 deletions

View File

@ -9,6 +9,10 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Run Deploy Script
- name: Trigger Automated Deployment
run: |
echo "Deployment triggered by Gitea Actions push event."
echo "Starting automatic deployment for dana-medical..."
docker run --rm \
-v /home/mikail/dana-medical:/app \
-v /var/run/docker.sock:/var/run/docker.sock \
docker:cli sh -c "cd /app && ./deploy.sh"

View File

@ -137,6 +137,10 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("PUT /api/v1/admin/services/{id}", a.requireAuth(a.adminUpdateService))
mux.HandleFunc("POST /api/v1/onec/events", a.onecEvents)
mux.HandleFunc("POST /api/v1/onec/sync/all", a.onecSyncAll)
mux.HandleFunc("GET /api/v1/onec/appointments/pending", a.onecPendingAppointments)
mux.HandleFunc("POST /api/v1/onec/appointments/{id}/confirm", a.onecConfirmAppointment)
mux.HandleFunc("POST /api/v1/onec/appointments/{id}/reject", a.onecRejectAppointment)
mux.HandleFunc("GET /api/v1/admin/onec", a.requireAuth(a.adminOnec))
mux.HandleFunc("POST /api/v1/admin/onec/sync", a.requireAuth(a.adminOnecSync))
mux.HandleFunc("PUT /api/v1/admin/onec/doctors/{onec_id}/link", a.requireAuth(a.adminOnecLink))

View File

@ -325,65 +325,100 @@ func (a *API) createAppointment(w http.ResponseWriter, r *http.Request) {
return
}
// Окно ещё свободно? Спрашиваем 1С по этому врачу на этот день.
dayFrom := time.Date(start.In(moscow).Year(), start.In(moscow).Month(), start.In(moscow).Day(), 0, 0, 0, 0, moscow)
dayTo := dayFrom.AddDate(0, 0, 1)
fresh, err := a.onec.Schedule(ctx, dayFrom, dayTo, onecID)
if err != nil {
a.appointmentFallback(w, r, in, doctorID, rec, err)
return
}
_ = a.store.ReplaceOnecDoctorSlots(ctx, onecID, dayFrom, dayTo, fresh)
var slot *onec.Slot
for i := range fresh {
s := fresh[i]
if s.Free() && s.Start.Equal(start) && (in.BranchID == "" || s.BranchID == in.BranchID) {
slot = &s
break
var end time.Time
var apptID string
if a.onec.Configured() {
// Окно ещё свободно? Спрашиваем 1С по этому врачу на этот день.
dayFrom := time.Date(start.In(moscow).Year(), start.In(moscow).Month(), start.In(moscow).Day(), 0, 0, 0, 0, moscow)
dayTo := dayFrom.AddDate(0, 0, 1)
fresh, err := a.onec.Schedule(ctx, dayFrom, dayTo, onecID)
if err != nil {
a.appointmentFallback(w, r, in, doctorID, rec, err)
return
}
_ = a.store.ReplaceOnecDoctorSlots(ctx, onecID, dayFrom, dayTo, fresh)
for i := range fresh {
s := fresh[i]
if s.Free() && s.Start.Equal(start) && (in.BranchID == "" || s.BranchID == in.BranchID) {
slot = &s
break
}
}
if slot == nil {
a.slotTaken(w, r, rec, onecID, "окно уже занято")
return
}
}
if slot == nil {
a.slotTaken(w, r, rec, onecID, "окно уже занято")
return
}
appt, err := a.onec.Book(ctx, onec.AppointmentRequest{
RequestID: in.RequestID, DoctorID: onecID, BranchID: slot.BranchID, ServiceID: in.ServiceID, SlotID: slot.SlotID,
Start: slot.Start, Name: in.Name, Phone: in.Phone, BirthDate: in.BirthDate, Comment: in.Comment, Source: onecSourceLabel,
})
switch {
case errors.Is(err, onec.ErrSlotBusy):
appt, err := a.onec.Book(ctx, onec.AppointmentRequest{
RequestID: in.RequestID, DoctorID: onecID, BranchID: slot.BranchID, ServiceID: in.ServiceID, SlotID: slot.SlotID,
Start: slot.Start, Name: in.Name, Phone: in.Phone, BirthDate: in.BirthDate, Comment: in.Comment, Source: onecSourceLabel,
})
switch {
case errors.Is(err, onec.ErrSlotBusy):
_ = a.store.MarkOnecSlotBusy(ctx, onecID, slot.BranchID, slot.Start)
a.slotTaken(w, r, rec, onecID, err.Error())
return
case err != nil:
a.appointmentFallback(w, r, in, doctorID, rec, err)
return
}
_ = a.store.MarkOnecSlotBusy(ctx, onecID, slot.BranchID, slot.Start)
a.slotTaken(w, r, rec, onecID, err.Error())
return
case err != nil:
a.appointmentFallback(w, r, in, doctorID, rec, err)
return
}
_ = a.store.MarkOnecSlotBusy(ctx, onecID, slot.BranchID, slot.Start)
end := appt.End
if end.IsZero() {
end = appt.End
if end.IsZero() {
end = slot.End
}
apptID = appt.ID
} else {
// Режим Push/Pull (1С сама опрашивает очередь по регламентному заданию)
localSlots, err := a.store.FreeOnecSlots(ctx, onecID, start, start.Add(time.Minute))
if err != nil || len(localSlots) == 0 {
a.slotTaken(w, r, rec, onecID, "окно уже занято")
return
}
for i := range localSlots {
ls := localSlots[i]
if in.BranchID == "" || ls.BranchID == in.BranchID {
slot = &onec.Slot{
DoctorID: onecID,
BranchID: ls.BranchID,
Start: ls.Start,
End: ls.End,
Status: "free",
}
break
}
}
if slot == nil {
a.slotTaken(w, r, rec, onecID, "окно уже занято")
return
}
_ = a.store.MarkOnecSlotBusy(ctx, onecID, slot.BranchID, slot.Start)
end = slot.End
}
branch := a.store.OnecBranchName(ctx, slot.BranchID)
confirm := a.bookingConfirmEnabled()
note := "Запись подтверждена в 1С"
if confirm {
note = "Запись сделана в 1С, ждёт подтверждения оператором в Битрикс24"
}
leadID := a.saveAppointmentLead(ctx, r, in, doctorID, fmt.Sprintf("%s (№ %s): %s, %s.%s",
note, appt.ID, formatSlotTime(slot.Start), branchOrDash(branch), a.serviceNote(ctx, onecID, in.ServiceID)), map[string]any{
"onec_appointment_id": appt.ID, "onec_doctor_id": onecID, "slot_start": slot.Start.Format(time.RFC3339),
leadID := a.saveAppointmentLead(ctx, r, in, doctorID, fmt.Sprintf("%s: %s, %s.%s",
note, formatSlotTime(slot.Start), branchOrDash(branch), a.serviceNote(ctx, onecID, in.ServiceID)), map[string]any{
"onec_appointment_id": apptID, "onec_doctor_id": onecID, "slot_start": slot.Start.Format(time.RFC3339),
})
if err := a.store.FinishOnecAppointment(context.Background(), rec.ID, "booked", appt.ID, "", &end, leadID); err != nil {
slog.Error("onec appointment finish", "id", rec.ID, "err", err)
}
if confirm {
if err := a.store.StartBookingConfirm(context.Background(), rec.ID); err != nil {
slog.Error("booking confirm start", "id", rec.ID, "err", err)
if apptID != "" {
if err := a.store.FinishOnecAppointment(context.Background(), rec.ID, "booked", apptID, "", &end, leadID); err != nil {
slog.Error("onec appointment finish", "id", rec.ID, "err", err)
}
if confirm {
if err := a.store.StartBookingConfirm(context.Background(), rec.ID); err != nil {
slog.Error("booking confirm start", "id", rec.ID, "err", err)
}
a.kickConfirm()
}
a.kickConfirm()
}
wcm.consume(in.VerificationID)
writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{

208
internal/api/onec_push.go Normal file
View File

@ -0,0 +1,208 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"dana/internal/onec"
)
// onecAuth проверяет API-ключ входящих запросов от 1С.
func (a *API) checkOnecKey(r *http.Request) bool {
key := r.Header.Get("X-Api-Key")
if key == "" {
key = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
key = strings.TrimSpace(key)
if key == "" {
return false
}
if a.onecHook != "" && sameKey(key, a.onecHook) {
return true
}
if a.onec != nil && a.onec.Key != "" && sameKey(key, a.onec.Key) {
return true
}
return false
}
type onecSyncPayload struct {
Branches []onec.Branch `json:"branches"`
Doctors []onec.Doctor `json:"doctors"`
Services []onecDoctorServices `json:"services"`
Slots []onec.Slot `json:"slots"`
}
type onecDoctorServices struct {
DoctorID string `json:"doctor_id"`
Services []onec.Service `json:"services"`
}
// onecSyncAll принимает полный слепок каталога и расписания от регламентного задания 1С.
func (a *API) onecSyncAll(w http.ResponseWriter, r *http.Request) {
if !a.checkOnecKey(r) {
fail(w, http.StatusUnauthorized, "unauthorized", "Неверный или отсутствующий API-ключ")
return
}
var payload onecSyncPayload
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<20)).Decode(&payload); err != nil {
fail(w, http.StatusBadRequest, "bad_json", "Не удалось разобрать тело запроса: "+err.Error())
return
}
ctx := r.Context()
linked := 0
var err error
// 1. Сохраняем филиалы и врачей
if len(payload.Branches) > 0 || len(payload.Doctors) > 0 {
linked, err = a.store.SaveOnecDirectory(ctx, payload.Branches, payload.Doctors)
if err != nil {
fail(w, http.StatusInternalServerError, "db_error", "Ошибка сохранения справочников: "+err.Error())
return
}
}
// 2. Сохраняем услуги врачей
for _, ds := range payload.Services {
if ds.DoctorID != "" && len(ds.Services) > 0 {
if err := a.store.SaveOnecServices(ctx, ds.DoctorID, ds.Services); err != nil {
fail(w, http.StatusInternalServerError, "db_error", "Ошибка сохранения услуг: "+err.Error())
return
}
}
}
// 3. Сохраняем слоты расписания
if len(payload.Slots) > 0 {
minStart, maxEnd := findSlotBounds(payload.Slots)
if err := a.store.ReplaceOnecSlots(ctx, minStart, maxEnd, payload.Slots); err != nil {
fail(w, http.StatusInternalServerError, "db_error", "Ошибка сохранения слотов: "+err.Error())
return
}
}
ok(w, map[string]any{
"ok": true,
"linked": linked,
"branches": len(payload.Branches),
"doctors": len(payload.Doctors),
"services": len(payload.Services),
"slots": len(payload.Slots),
}, nil)
}
func findSlotBounds(slots []onec.Slot) (time.Time, time.Time) {
if len(slots) == 0 {
now := time.Now()
return now, now
}
minT := slots[0].Start
maxT := slots[0].End
for _, s := range slots[1:] {
if s.Start.Before(minT) {
minT = s.Start
}
if s.End.After(maxT) {
maxT = s.End
}
}
return minT, maxT
}
// onecPendingAppointments отдает 1С пачку ожидающих подтверждения записей.
func (a *API) onecPendingAppointments(w http.ResponseWriter, r *http.Request) {
if !a.checkOnecKey(r) {
fail(w, http.StatusUnauthorized, "unauthorized", "Неверный или отсутствующий API-ключ")
return
}
list, err := a.store.PendingOnecAppointments(r.Context(), 50)
if err != nil {
fail(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
ok(w, map[string]any{
"ok": true,
"appointments": list,
}, nil)
}
type confirmAppointmentReq struct {
OnecAppointmentID string `json:"onec_appointment_id"`
End *time.Time `json:"end"`
}
// onecConfirmAppointment подтверждает создание записи в 1С.
func (a *API) onecConfirmAppointment(w http.ResponseWriter, r *http.Request) {
if !a.checkOnecKey(r) {
fail(w, http.StatusUnauthorized, "unauthorized", "Неверный или отсутствующий API-ключ")
return
}
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
fail(w, http.StatusBadRequest, "bad_id", "Некорректный идентификатор записи")
return
}
var req confirmAppointmentReq
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
fail(w, http.StatusBadRequest, "bad_json", "Не удалось разобрать тело запроса")
return
}
if req.OnecAppointmentID == "" {
fail(w, http.StatusBadRequest, "missing_param", "onec_appointment_id обязателен")
return
}
if err := a.store.ConfirmPendingAppointment(r.Context(), id, req.OnecAppointmentID, req.End); err != nil {
fail(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
if a.bookingConfirmEnabled() {
_ = a.store.StartBookingConfirm(r.Context(), id)
a.kickConfirm()
}
ok(w, map[string]any{"ok": true, "id": id, "status": "booked"}, nil)
}
type rejectAppointmentReq struct {
Reason string `json:"reason"`
}
// onecRejectAppointment отклоняет запись (например, слот уже занят).
func (a *API) onecRejectAppointment(w http.ResponseWriter, r *http.Request) {
if !a.checkOnecKey(r) {
fail(w, http.StatusUnauthorized, "unauthorized", "Неверный или отсутствующий API-ключ")
return
}
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
fail(w, http.StatusBadRequest, "bad_id", "Некорректный идентификатор записи")
return
}
var req rejectAppointmentReq
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
req.Reason = "rejected by 1C"
}
if err := a.store.RejectPendingAppointment(r.Context(), id, req.Reason); err != nil {
fail(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
ok(w, map[string]any{"ok": true, "id": id, "status": "failed"}, nil)
}

View File

@ -0,0 +1,69 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"dana/internal/onec"
)
func TestOnecPushEndpoints(t *testing.T) {
app := &API{
onecHook: "test-secret-key",
onec: onec.New("", "test-secret-key", "X-Api-Key"),
}
mux := app.Routes()
// 1. Unauthorized request
req := httptest.NewRequest(http.MethodGet, "/api/v1/onec/appointments/pending", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 unauthorized, got %d", w.Code)
}
// 2. Authorized check with invalid body
req = httptest.NewRequest(http.MethodPost, "/api/v1/onec/sync/all", bytes.NewReader([]byte("{invalid-json")))
req.Header.Set("X-Api-Key", "test-secret-key")
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 bad request, got %d", w.Code)
}
// 3. FindSlotBounds test
now := time.Now()
slots := []onec.Slot{
{Start: now.Add(time.Hour), End: now.Add(2 * time.Hour)},
{Start: now, End: now.Add(30 * time.Minute)},
{Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour)},
}
minT, maxT := findSlotBounds(slots)
if !minT.Equal(now) {
t.Errorf("expected minT = %v, got %v", now, minT)
}
if !maxT.Equal(now.Add(4 * time.Hour)) {
t.Errorf("expected maxT = %v, got %v", now.Add(4*time.Hour), maxT)
}
}
func TestOnecConfirmBadID(t *testing.T) {
app := &API{
onecHook: "test-secret-key",
}
mux := app.Routes()
body, _ := json.Marshal(confirmAppointmentReq{OnecAppointmentID: "001"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/onec/appointments/invalid-id/confirm", bytes.NewReader(body))
req.Header.Set("X-Api-Key", "test-secret-key")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 bad id, got %d", w.Code)
}
}

View File

@ -597,3 +597,56 @@ func (s *Storage) SetOnecAppointmentStatus(ctx context.Context, onecAppointmentI
}
return doctorOnecID, start, true, nil
}
type OnecPendingAppointment struct {
ID int64 `json:"id"`
RequestID string `json:"request_id"`
DoctorID string `json:"doctor_id"`
BranchID string `json:"branch_id"`
ServiceID string `json:"service_id"`
Start time.Time `json:"start"`
Patient string `json:"patient_name"`
Phone string `json:"patient_phone"`
CreatedAt time.Time `json:"created_at"`
}
func (s *Storage) PendingOnecAppointments(ctx context.Context, limit int) ([]OnecPendingAppointment, error) {
if limit <= 0 {
limit = 20
}
rows, err := s.pool.Query(ctx, `
SELECT id, request_id, doctor_onec_id, branch_onec_id, COALESCE(service_onec_id, ''), start_at, patient_name, patient_phone, created_at
FROM onec_appointments
WHERE status = 'pending'
ORDER BY id ASC
LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []OnecPendingAppointment{}
for rows.Next() {
var a OnecPendingAppointment
if err := rows.Scan(&a.ID, &a.RequestID, &a.DoctorID, &a.BranchID, &a.ServiceID, &a.Start, &a.Patient, &a.Phone, &a.CreatedAt); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (s *Storage) ConfirmPendingAppointment(ctx context.Context, id int64, onecID string, end *time.Time) error {
_, err := s.pool.Exec(ctx, `
UPDATE onec_appointments
SET status = 'booked', onec_appointment_id = $2, end_at = $3, updated_at = now()
WHERE id = $1 AND status = 'pending'`, id, onecID, end)
return err
}
func (s *Storage) RejectPendingAppointment(ctx context.Context, id int64, reason string) error {
_, err := s.pool.Exec(ctx, `
UPDATE onec_appointments
SET status = 'failed', error = $2, updated_at = now()
WHERE id = $1 AND status = 'pending'`, id, reason)
return err
}