fix(qsl): separate the QSL manager from the routing method (#16)

QSL_VIA is the manager. QSL_SENT_VIA and QSL_RCVD_VIA are the ADIF "QSL Via"
enumeration — B bureau, D direct, E electronic, M manager (import-only) —
and say how a card travelled. OpsLog had one column for all three:

  - the import folded QSL_SENT_VIA into QSL_VIA whenever QSL_VIA was empty,
    which is exactly a Log4OM export (it defaults QSL_SENT_VIA to E), so
    OE6CLD saw "E" everywhere OpsLog shows the manager;
  - QSL_RCVD_VIA was listed in adifPromoted with no column behind it, so it
    was not stored, not kept among the extras, and not exported — dropped
    outright on import;
  - neither was ever written on export, so an import followed by an export
    destroyed both;
  - and OpsLog polluted the field itself: the QSL Manager panel wrote
    "Bureau" / "Direct" / "Electronic", in full words, into QSL_VIA.

Two columns added (migration 0027), carried through the five places a
promoted ADIF field has to touch, with round-trip tests pinning the reported
case. The QSL panel now offers Bureau / Direct / Electronic for each
direction and stores the enumeration; the manager field is labelled as the
manager and holds only that. M is kept when a file gives it and never
written back out.

Existing logs hold a mixture of the two in one column. The repair is offered,
not performed: the count is shown once per log with a plain question, and a
"no" is remembered. It moves only where QSL_SENT_VIA is still empty, and only
values that normalise to the enumeration — a manager is a callsign and can
never be one of those six words, which a test pins against real manager calls.
This commit is contained in:
2026-08-14 11:50:25 +02:00
parent 30143b01bf
commit 4e88bdfaa7
20 changed files with 542 additions and 35 deletions
+86 -2
View File
@@ -6223,8 +6223,12 @@ func (a *App) BulkUpdateQSL(ids []int64, u QSLBulkUpdate) (int, error) {
if v := strings.TrimSpace(u.RcvdDate); v != "" {
q.QSLRcvdDate, changed = v, true
}
if v := strings.TrimSpace(u.Via); v != "" {
q.QSLVia, changed = v, true
// The QSL Manager panel's "Via" is a routing method (bureau / direct /
// electronic), so it belongs in QSL_SENT_VIA, not in QSL_VIA — which
// holds the manager. It used to write the manager field, in full words,
// which is half of why the two got mixed up in operators' logs.
if v := adif.NormaliseQSLVia(u.Via); v != "" {
q.QSLSentVia, changed = v, true
}
if v := strings.TrimSpace(u.Notes); v != "" {
q.Notes, changed = v, true
@@ -6255,6 +6259,8 @@ var bulkFieldColumns = map[string]string{
"qsl_sent": "qsl_sent",
"qsl_rcvd": "qsl_rcvd",
"qsl_via": "qsl_via",
"qsl_sent_via": "qsl_sent_via",
"qsl_rcvd_via": "qsl_rcvd_via",
"qrz_sent": "qrzcom_qso_upload_status",
"qrz_rcvd": "qrzcom_qso_download_status",
"clublog_sent": "clublog_qso_upload_status",
@@ -10544,6 +10550,84 @@ func (a *App) DownloadULSCounties() error {
}
// BackfillUSCountiesResult summarises a bulk county/grid backfill over the log.
// ── QSL routing repair (QSL_VIA vs QSL_SENT_VIA) ───────────────────────
// keyQSLViaRepairDone records that the operator has answered the offer to move
// routing words out of the manager field — whether they accepted or declined.
// Asked once per log, never again: a repair that keeps proposing itself after a
// "no" is a nag, and the answer does not change.
const keyQSLViaRepairDone = "migr.qsl_via_routing.v1"
// QSLViaRepairStatus counts the QSOs whose QSL_VIA holds a routing method
// rather than a manager, so the operator can be shown a number before anything
// is touched.
type QSLViaRepairStatus struct {
Affected int `json:"affected"` // QSOs holding a routing word in qsl_via
Asked bool `json:"asked"` // the offer has already been answered
}
// QSLViaRepairStatus reports whether this log needs the QSL_VIA repair.
//
// OpsLog wrote "Bureau" / "Direct" / "Electronic" into QSL_VIA from its own QSL
// Manager panel, and ADIF imports folded QSL_SENT_VIA there as well, so the
// manager column in an existing log is a mixture of the two. Moving them is
// safe — a manager is a callsign and can never be one of those words — but it
// rewrites what an operator sees in their own log, so it is counted and offered
// rather than done.
func (a *App) QSLViaRepairStatus() (QSLViaRepairStatus, error) {
var res QSLViaRepairStatus
if a.qso == nil {
return res, fmt.Errorf("db not initialized")
}
if a.settings != nil {
if v, _ := a.settings.GetGlobal(a.ctx, keyQSLViaRepairDone); v == "1" {
res.Asked = true
}
}
n, err := a.qso.CountQSLViaRouting(a.ctx, adif.IsQSLViaRouting)
if err != nil {
return res, err
}
res.Affected = n
return res, nil
}
// QSLViaRepairResult reports what the repair moved.
type QSLViaRepairResult struct {
Moved int `json:"moved"`
}
// RepairQSLVia moves routing words out of QSL_VIA into QSL_SENT_VIA.
//
// Only where QSL_SENT_VIA is still empty: a QSO that already carries a real
// sent-via — from an import made after this was fixed — knows better than a
// word left in the manager column, and must not be overwritten by it.
func (a *App) RepairQSLVia() (QSLViaRepairResult, error) {
var res QSLViaRepairResult
if a.qso == nil {
return res, fmt.Errorf("db not initialized")
}
n, err := a.qso.RepairQSLViaRouting(a.ctx, adif.NormaliseQSLVia)
res.Moved = n
if err != nil {
return res, err
}
a.markQSLViaRepairAsked()
applog.Printf("qsl via repair: moved %d routing values out of the manager field", n)
return res, nil
}
// DismissQSLViaRepair records a "no" so the offer is not made again.
func (a *App) DismissQSLViaRepair() {
a.markQSLViaRepairAsked()
}
func (a *App) markQSLViaRepairAsked() {
if a.settings != nil {
_ = a.settings.SetGlobal(a.ctx, keyQSLViaRepairDone, "1")
}
}
// keyDistanceBackfilled marks the one-time distance fill as done.
//
// A migration, not a setting. It was briefly a button in Preferences, which was