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:
+93
-6
@@ -99,7 +99,9 @@ type QSO struct {
|
||||
QSLRcvd string `json:"qsl_rcvd,omitempty"`
|
||||
QSLSentDate string `json:"qsl_sent_date,omitempty"`
|
||||
QSLRcvdDate string `json:"qsl_rcvd_date,omitempty"`
|
||||
QSLVia string `json:"qsl_via,omitempty"`
|
||||
QSLVia string `json:"qsl_via,omitempty"` // ADIF QSL_VIA — the QSL manager
|
||||
QSLSentVia string `json:"qsl_sent_via,omitempty"` // ADIF enumeration B/D/E — how the card was sent
|
||||
QSLRcvdVia string `json:"qsl_rcvd_via,omitempty"` // same enumeration, for the card received
|
||||
QSLMsg string `json:"qsl_msg,omitempty"`
|
||||
QSLMsgRcvd string `json:"qslmsg_rcvd,omitempty"`
|
||||
|
||||
@@ -246,7 +248,7 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
|
||||
grid, gridsquare_ext, vucc_grids,
|
||||
country, state, cnty, dxcc, cont, cqz, ituz, iota, sota_ref, pota_ref,
|
||||
age, lat, lon, rig, ant,
|
||||
qsl_sent, qsl_rcvd, qsl_sent_date, qsl_rcvd_date, qsl_via, qsl_msg, qslmsg_rcvd,
|
||||
qsl_sent, qsl_rcvd, qsl_sent_date, qsl_rcvd_date, qsl_via, qsl_sent_via, qsl_rcvd_via, qsl_msg, qslmsg_rcvd,
|
||||
lotw_sent, lotw_rcvd, lotw_sent_date, lotw_rcvd_date,
|
||||
eqsl_sent, eqsl_rcvd, eqsl_sent_date, eqsl_rcvd_date,
|
||||
clublog_qso_upload_date, clublog_qso_upload_status,
|
||||
@@ -321,7 +323,7 @@ func (q *QSO) args() []any {
|
||||
q.Grid, q.GridExt, q.VUCCGrids,
|
||||
q.Country, q.State, q.County, q.DXCC, q.Continent, q.CQZ, q.ITUZ, q.IOTA, q.SOTARef, q.POTARef,
|
||||
q.Age, q.Lat, q.Lon, q.Rig, q.Ant,
|
||||
q.QSLSent, q.QSLRcvd, q.QSLSentDate, q.QSLRcvdDate, q.QSLVia, q.QSLMsg, q.QSLMsgRcvd,
|
||||
q.QSLSent, q.QSLRcvd, q.QSLSentDate, q.QSLRcvdDate, q.QSLVia, q.QSLSentVia, q.QSLRcvdVia, q.QSLMsg, q.QSLMsgRcvd,
|
||||
q.LOTWSent, q.LOTWRcvd, q.LOTWSentDate, q.LOTWRcvdDate,
|
||||
q.EQSLSent, q.EQSLRcvd, q.EQSLSentDate, q.EQSLRcvdDate,
|
||||
q.ClublogUploadDate, q.ClublogUploadStatus,
|
||||
@@ -771,6 +773,8 @@ var bulkEditableCols = map[string]bool{
|
||||
"qsl_sent": true,
|
||||
"qsl_rcvd": true,
|
||||
"qsl_via": true,
|
||||
"qsl_sent_via": true,
|
||||
"qsl_rcvd_via": true,
|
||||
"qrzcom_qso_upload_status": true,
|
||||
"qrzcom_qso_download_status": true,
|
||||
"clublog_qso_upload_status": true,
|
||||
@@ -1250,7 +1254,7 @@ var filterableColumns = map[string]bool{
|
||||
"grid": true, "country": true, "state": true, "cnty": true,
|
||||
"dxcc": true, "cont": true, "cqz": true, "ituz": true,
|
||||
"iota": true, "sota_ref": true, "pota_ref": true, "wwff_ref": true, "rig": true, "ant": true,
|
||||
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true,
|
||||
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true, "qsl_sent_via": true, "qsl_rcvd_via": true,
|
||||
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
|
||||
"qrzcom_qso_upload_status": true, "qrzcom_qso_download_status": true,
|
||||
"clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
|
||||
@@ -2265,6 +2269,86 @@ func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty stri
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountQSLViaRouting counts the QSOs whose qsl_via holds a routing method
|
||||
// instead of a manager, per isRouting.
|
||||
//
|
||||
// The test is applied in Go rather than in SQL because it has to hold the same
|
||||
// vocabulary as the import and the QSL panel — one list of accepted spellings,
|
||||
// in internal/adif, not a LIKE pattern drifting apart from it here. Only the
|
||||
// distinct values are examined, so the log is scanned once whatever its size.
|
||||
func (r *Repo) CountQSLViaRouting(ctx context.Context, isRouting func(string) bool) (int, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT qsl_via, COUNT(*) FROM qso
|
||||
WHERE qsl_via IS NOT NULL AND qsl_via != ''
|
||||
AND (qsl_sent_via IS NULL OR qsl_sent_via = '')
|
||||
GROUP BY qsl_via`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
n := 0
|
||||
for rows.Next() {
|
||||
var via string
|
||||
var c int
|
||||
if err := rows.Scan(&via, &c); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if isRouting(via) {
|
||||
n += c
|
||||
}
|
||||
}
|
||||
return n, rows.Err()
|
||||
}
|
||||
|
||||
// RepairQSLViaRouting moves routing words out of qsl_via into qsl_sent_via,
|
||||
// returning how many QSOs were changed.
|
||||
//
|
||||
// One UPDATE per distinct spelling, not per QSO: a log holds a handful of them
|
||||
// ("Bureau", "E", "Direct"…), and a remote MySQL logbook must not be made to
|
||||
// carry one round trip per contact for a tidy-up. Rows that already have a
|
||||
// sent-via are left alone by the same condition the count uses, so running this
|
||||
// twice cannot undo a later import.
|
||||
func (r *Repo) RepairQSLViaRouting(ctx context.Context, normalise func(string) string) (int, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT DISTINCT qsl_via FROM qso
|
||||
WHERE qsl_via IS NOT NULL AND qsl_via != ''
|
||||
AND (qsl_sent_via IS NULL OR qsl_sent_via = '')`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
type move struct{ from, to string }
|
||||
var moves []move
|
||||
for rows.Next() {
|
||||
var via string
|
||||
if err := rows.Scan(&via); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
if to := normalise(via); to != "" {
|
||||
moves = append(moves, move{from: via, to: to})
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, m := range moves {
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET qsl_sent_via = ?, qsl_via = ''
|
||||
WHERE qsl_via = ? AND (qsl_sent_via IS NULL OR qsl_sent_via = '')`,
|
||||
m.to, m.from)
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err == nil {
|
||||
total += int(n)
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// CallCounties returns callsign → "STATE,County" for every US station already
|
||||
// logged with a county, newest QSO winning.
|
||||
//
|
||||
@@ -2889,7 +2973,8 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
rig, ant sql.NullString
|
||||
qslSent, qslRcvd sql.NullString
|
||||
qslSentDate, qslRcvdDate sql.NullString
|
||||
qslVia, qslMsg, qslMsgRcvd sql.NullString
|
||||
qslVia, qslSentVia, qslRcvdVia sql.NullString
|
||||
qslMsg, qslMsgRcvd sql.NullString
|
||||
lotwSent, lotwRcvd sql.NullString
|
||||
lotwSentDate, lotwRcvdDate sql.NullString
|
||||
eqslSent, eqslRcvd sql.NullString
|
||||
@@ -2934,7 +3019,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
&grid, &gridExt, &vucc,
|
||||
&country, &state, &cnty, &dxcc, &cont, &cqz, &ituz, &iota, &sota, &pota,
|
||||
&age, &lat, &lon, &rig, &ant,
|
||||
&qslSent, &qslRcvd, &qslSentDate, &qslRcvdDate, &qslVia, &qslMsg, &qslMsgRcvd,
|
||||
&qslSent, &qslRcvd, &qslSentDate, &qslRcvdDate, &qslVia, &qslSentVia, &qslRcvdVia, &qslMsg, &qslMsgRcvd,
|
||||
&lotwSent, &lotwRcvd, &lotwSentDate, &lotwRcvdDate,
|
||||
&eqslSent, &eqslRcvd, &eqslSentDate, &eqslRcvdDate,
|
||||
&clublogDate, &clublogStatus,
|
||||
@@ -3018,6 +3103,8 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
q.QSLSentDate = qslSentDate.String
|
||||
q.QSLRcvdDate = qslRcvdDate.String
|
||||
q.QSLVia = qslVia.String
|
||||
q.QSLSentVia = qslSentVia.String
|
||||
q.QSLRcvdVia = qslRcvdVia.String
|
||||
q.QSLMsg = qslMsg.String
|
||||
q.QSLMsgRcvd = qslMsgRcvd.String
|
||||
q.LOTWSent = lotwSent.String
|
||||
|
||||
Reference in New Issue
Block a user