From 3e5a31a5bfc3f1e2c405cef0511a897510f012ea Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Mon, 1 Jun 2026 12:01:13 -0400 Subject: [PATCH] Add modular SQL/DB VBA library and TBFC forecast loader Start a modern, modular successor to TheBigOne (side-by-side; TheBigOne untouched): - Sql.bas: stateless SQL-text generation from a 2D Variant array (header in row 1). Type inference from Excel cell VarType, dialect-aware identifier quoting/literals, VALUES batch + INSERT...SELECT, and CreateTableIfMissing. Successor to the SQLp_* helpers. - Db.cls: single connection object, late-bound ADO (no reference needed), SQLOLEDB by default (zero-install / non-admin friendly). Open/Exec/Query with structured LastError. Successor to the ADOp_* helpers. - TBFCLoad.bas: thin caller that loads the "Upload" tab into fanalysis.GS.TBFC (auto-create, replace-by-Source). Tested vs USMIDSQL01. - load_tbfc.py: equivalent Python loader kept as a power-user alternative. Co-Authored-By: Claude Opus 4.8 (1M context) --- Db.cls | 201 +++++++++++++++++++++++++++++++++++++++++++ Sql.bas | 239 +++++++++++++++++++++++++++++++++++++++++++++++++++ TBFCLoad.bas | 93 ++++++++++++++++++++ load_tbfc.py | 150 ++++++++++++++++++++++++++++++++ 4 files changed, 683 insertions(+) create mode 100644 Db.cls create mode 100644 Sql.bas create mode 100644 TBFCLoad.bas create mode 100644 load_tbfc.py diff --git a/Db.cls b/Db.cls new file mode 100644 index 0000000..c6aedab --- /dev/null +++ b/Db.cls @@ -0,0 +1,201 @@ +VERSION 1.0 CLASS +BEGIN + MultiUse = -1 'True +END +Attribute VB_Name = "Db" +Attribute VB_GlobalNameSpace = False +Attribute VB_Creatable = False +Attribute VB_PredeclaredId = False +Attribute VB_Exposed = False +'========================================================================== +' Db - a single database connection (modern successor to the ADOp_* helpers +' in TheBigOne). Late-bound ADO, so no project reference is required; +' import the class and go. +' +' Defaults to the SQLOLEDB OLE DB provider (built into Windows - zero install, +' works for non-admin users), but Provider is settable. +' +' Dim db As New Db +' If db.OpenSqlServer("USMIDSQL01", "fanalysis") Then +' If db.Exec(batchSql) Then ... Else MsgBox db.LastError +' Dim a As Variant: a = db.Query("SELECT ...") ' 2D, row 1 = headers +' db.CloseConn +' End If +'========================================================================== + +Option Explicit + +Private mConn As Object ' ADODB.Connection (late bound) +Private mErr As String +Private mProvider As String +Private mTimeout As Long +Private mRows As Long + + +Private Sub Class_Initialize() + mProvider = "SQLOLEDB" + mTimeout = 600 +End Sub + +Private Sub Class_Terminate() + CloseConn + Set mConn = Nothing +End Sub + + +'----- properties --------------------------------------------------------- + +Public Property Get Provider() As String + Provider = mProvider +End Property +Public Property Let Provider(ByVal value As String) + mProvider = value +End Property + +Public Property Get CommandTimeout() As Long + CommandTimeout = mTimeout +End Property +Public Property Let CommandTimeout(ByVal value As Long) + mTimeout = value +End Property + +Public Property Get LastError() As String + LastError = mErr +End Property + +Public Property Get RowsAffected() As Long + RowsAffected = mRows +End Property + +Public Property Get IsOpen() As Boolean + IsOpen = (Not mConn Is Nothing) + If IsOpen Then IsOpen = (mConn.State <> 0) +End Property + + +'----- open / close ------------------------------------------------------- + +' Open a SQL Server connection. Integrated (Windows) security by default. +Public Function OpenSqlServer(ByVal server As String, _ + Optional ByVal database As String = "", _ + Optional ByVal integratedSecurity As Boolean = True, _ + Optional ByVal user As String = "", _ + Optional ByVal password As String = "") As Boolean + Dim cs As String + cs = "Provider=" & mProvider & ";Data Source=" & server & ";" + If Len(database) > 0 Then cs = cs & "Initial Catalog=" & database & ";" + If integratedSecurity Then + cs = cs & "Integrated Security=SSPI;" + Else + cs = cs & "User ID=" & user & ";Password=" & password & ";" + End If + OpenSqlServer = OpenConnString(cs) +End Function + +' Open with any provided connection string. +Public Function OpenConnString(ByVal connString As String) As Boolean + On Error GoTo fail + mErr = "" + If mConn Is Nothing Then Set mConn = CreateObject("ADODB.Connection") + If mConn.State <> 0 Then mConn.Close + mConn.Open connString + OpenConnString = True + Exit Function +fail: + mErr = "Open failed: " & Err.Number & " - " & Err.Description + OpenConnString = False +End Function + +Public Sub CloseConn() + On Error Resume Next + If Not mConn Is Nothing Then + If mConn.State <> 0 Then mConn.Close + End If +End Sub + + +'----- execute / query ---------------------------------------------------- + +' Run a statement or multi-statement batch. RowsAffected is set after. +Public Function Exec(ByVal sql As String) As Boolean + On Error GoTo fail + mErr = "": mRows = 0 + If Not IsOpen Then + mErr = "Connection is not open." + Exec = False + Exit Function + End If + mConn.CommandTimeout = mTimeout + Dim affected As Variant + mConn.Execute sql, affected + If Not IsEmpty(affected) Then mRows = CLng(affected) + Exec = True + Exit Function +fail: + mErr = "Exec failed: " & Err.Number & " - " & Err.Description + Exec = False +End Function + +' Run a query and return a 2D Variant array (1-based). Row 1 = headers when +' includeHeaders. Returns Empty on error or no columns; check LastError. +Public Function Query(ByVal sql As String, Optional ByVal includeHeaders As Boolean = True) As Variant + On Error GoTo fail + mErr = "" + If Not IsOpen Then + mErr = "Connection is not open." + Query = Empty + Exit Function + End If + mConn.CommandTimeout = mTimeout + + Dim rs As Object + Set rs = mConn.Execute(sql) + Query = RecordsetToArray(rs, includeHeaders) + On Error Resume Next + rs.Close + Exit Function +fail: + mErr = "Query failed: " & Err.Number & " - " & Err.Description + Query = Empty +End Function + + +'----- helpers ------------------------------------------------------------ + +Private Function RecordsetToArray(ByVal rs As Object, ByVal includeHeaders As Boolean) As Variant + Dim nCols As Long, i As Long, j As Long + nCols = rs.Fields.Count + If nCols = 0 Then + RecordsetToArray = Empty + Exit Function + End If + + Dim block As Variant, nData As Long + If Not (rs.BOF And rs.EOF) Then + block = rs.GetRows ' 0-based (col, row) + nData = UBound(block, 2) - LBound(block, 2) + 1 + End If + + Dim hdrOff As Long + hdrOff = IIf(includeHeaders, 1, 0) + + If nData + hdrOff = 0 Then + RecordsetToArray = Empty + Exit Function + End If + + Dim out() As Variant + ReDim out(1 To nData + hdrOff, 1 To nCols) + If includeHeaders Then + For j = 0 To nCols - 1 + out(1, j + 1) = rs.Fields(j).Name + Next j + End If + For i = 0 To nData - 1 + For j = 0 To nCols - 1 + out(i + 1 + hdrOff, j + 1) = block(j, i) + Next j + Next i + + RecordsetToArray = out +End Function diff --git a/Sql.bas b/Sql.bas new file mode 100644 index 0000000..1e7ec6b --- /dev/null +++ b/Sql.bas @@ -0,0 +1,239 @@ +Attribute VB_Name = "Sql" +'========================================================================== +' Sql - generate SQL text from a 2D array (modern successor to SQLp_* in +' TheBigOne). Stateless, no connection, no ORM - it just writes SQL. +' +' Convention: callers pass a 2D Variant array whose FIRST ROW is the header +' (exactly what Range.Value returns for a block with a header row). Column +' SQL types are inferred from the Excel cell VarType per column: +' date cells -> DATE +' numeric cells -> DECIMAL(18, scale) +' everything else (incl. text-stored codes) -> N/VARCHAR(len) +' +' Final insert shape is INSERT ... SELECT * FROM (VALUES ...) x(cols) - a +' derived table, so SQL Server's 1000-row VALUES limit does not apply. +'========================================================================== + +Option Explicit + +Public Enum SqlDialect + dlSqlServer = 0 + dlPostgres = 1 +End Enum + +Public Enum ColKind + ckText = 0 + ckNumber = 1 + ckDate = 2 +End Enum + + +'----- low-level building blocks ------------------------------------------ + +' Quote an identifier for the dialect, escaping the close-quote char. +Public Function BracketId(ByVal name As String, ByVal dialect As SqlDialect) As String + If dialect = dlSqlServer Then + BracketId = "[" & Replace(name, "]", "]]") & "]" + Else + BracketId = """" & Replace(name, """", """""") & """" + End If +End Function + +' One SQL literal for a value of the given kind. maxLen > 0 truncates text. +Public Function Literal(ByVal v As Variant, ByVal kind As ColKind, _ + ByVal dialect As SqlDialect, Optional ByVal maxLen As Long = 0) As String + If IsEmpty(v) Or IsNull(v) Or IsError(v) Then + Literal = "NULL" + Exit Function + End If + + Select Case kind + Case ckDate + If IsDate(v) Then + Literal = "'" & Format$(CDate(v), "yyyy-mm-dd") & "'" + Else + Literal = "NULL" + End If + + Case ckNumber + If Not IsNumeric(v) Or Len(Trim$(CStr(v))) = 0 Then + Literal = "NULL" + Else + ' Str$ is locale-invariant ('.' decimal, leading space for sign) + Literal = Trim$(Str$(v)) + End If + + Case Else ' ckText + Dim s As String + s = CStr(v) + If maxLen > 0 And Len(s) > maxLen Then s = Left$(s, maxLen) + Literal = "'" & Replace(s, "'", "''") & "'" + End Select +End Function + +' Map a column kind to a SQL type. DECIMAL is valid in both dialects. +Public Function SqlType(ByVal kind As ColKind, ByVal dialect As SqlDialect, _ + Optional ByVal numScale As Long = 2, Optional ByVal textLen As Long = 100) As String + Select Case kind + Case ckDate + SqlType = "DATE" + Case ckNumber + SqlType = "DECIMAL(18," & numScale & ")" + Case Else + If dialect = dlSqlServer Then + SqlType = "NVARCHAR(" & textLen & ")" + Else + SqlType = "VARCHAR(" & textLen & ")" + End If + End Select +End Function + + +'----- inference & lookups ------------------------------------------------ + +' Infer a ColKind per column from the first non-empty data cell. +' Returns a Long() indexed to match the data's column bounds. +Public Function InferKinds(ByVal data As Variant) As Variant + Dim rLo As Long, rHi As Long, cLo As Long, cHi As Long, i As Long, j As Long + rLo = LBound(data, 1): rHi = UBound(data, 1) + cLo = LBound(data, 2): cHi = UBound(data, 2) + + Dim k() As Long + ReDim k(cLo To cHi) + For j = cLo To cHi + k(j) = ckText + For i = rLo + 1 To rHi ' skip header row + If Not IsEmpty(data(i, j)) Then + If Not IsError(data(i, j)) Then + Select Case VarType(data(i, j)) + Case vbDate + k(j) = ckDate + Case vbDouble, vbSingle, vbInteger, vbLong, vbCurrency, vbDecimal + k(j) = ckNumber + Case Else + k(j) = ckText + End Select + Exit For + End If + End If + Next i + Next j + InferKinds = k +End Function + +' 1-based-or-whatever column index of a header by name; 0 if not found. +Public Function ColIndex(ByVal data As Variant, ByVal headerName As String) As Long + Dim cLo As Long, cHi As Long, j As Long, hdr As Long + hdr = LBound(data, 1) + cLo = LBound(data, 2): cHi = UBound(data, 2) + For j = cLo To cHi + If StrComp(Trim$(CStr(data(hdr, j))), headerName, vbTextCompare) = 0 Then + ColIndex = j + Exit Function + End If + Next j + ColIndex = 0 +End Function + +' Distinct non-empty text values in a column (skips header). 0-based array. +Public Function DistinctText(ByVal data As Variant, ByVal colIndex As Long) As Variant + Dim d As Object + Set d = CreateObject("Scripting.Dictionary") + d.CompareMode = vbTextCompare + Dim rLo As Long, rHi As Long, i As Long, s As String + rLo = LBound(data, 1): rHi = UBound(data, 1) + For i = rLo + 1 To rHi + If Not IsEmpty(data(i, colIndex)) And Not IsError(data(i, colIndex)) Then + s = Trim$(CStr(data(i, colIndex))) + If Len(s) > 0 Then If Not d.Exists(s) Then d.Add s, True + End If + Next i + DistinctText = d.Keys +End Function + + +'----- composite generators ----------------------------------------------- + +' Comma list of bracketed header names, e.g. [Date], [Account GL], ... +Public Function ColumnList(ByVal data As Variant, ByVal dialect As SqlDialect) As String + Dim cLo As Long, cHi As Long, j As Long, hdr As Long + hdr = LBound(data, 1) + cLo = LBound(data, 2): cHi = UBound(data, 2) + Dim parts() As String + ReDim parts(cLo To cHi) + For j = cLo To cHi + parts(j) = BracketId(CStr(data(hdr, j)), dialect) + Next j + ColumnList = Join(parts, ", ") +End Function + +' Quote a 1D array of values as a comma list for an IN (...) clause (text). +Public Function InList(ByVal values As Variant, ByVal dialect As SqlDialect) As String + If IsEmpty(values) Then Exit Function + Dim lo As Long, hi As Long, i As Long + lo = LBound(values): hi = UBound(values) + If hi < lo Then Exit Function + Dim parts() As String + ReDim parts(lo To hi) + For i = lo To hi + parts(i) = "'" & Replace(CStr(values(i)), "'", "''") & "'" + Next i + InList = Join(parts, ", ") +End Function + +' SELECT * FROM (VALUES (..),(..)) x([col],[col]) over the data rows. +Public Function ValuesSelect(ByVal data As Variant, ByVal dialect As SqlDialect, _ + Optional ByRef kinds As Variant, Optional ByVal textLen As Long = 0) As String + Dim rLo As Long, rHi As Long, cLo As Long, cHi As Long, i As Long, j As Long, r As Long + rLo = LBound(data, 1): rHi = UBound(data, 1) + cLo = LBound(data, 2): cHi = UBound(data, 2) + If IsMissing(kinds) Then kinds = InferKinds(data) + + Dim rows() As String, cells() As String + ReDim rows(1 To rHi - rLo) ' data rows = total - header + ReDim cells(cLo To cHi) + For i = rLo + 1 To rHi + For j = cLo To cHi + cells(j) = Literal(data(i, j), kinds(j), dialect, textLen) + Next j + r = r + 1 + rows(r) = "(" & Join(cells, ",") & ")" + Next i + + ValuesSelect = "SELECT * FROM (VALUES" & vbCrLf & _ + Join(rows, "," & vbCrLf) & vbCrLf & _ + ") x (" & ColumnList(data, dialect) & ")" +End Function + +' Full INSERT INTO (cols) SELECT * FROM (VALUES ...) x(cols). +Public Function InsertSelectValues(ByVal fq As String, ByVal data As Variant, ByVal dialect As SqlDialect, _ + Optional ByRef kinds As Variant, Optional ByVal textLen As Long = 0) As String + If IsMissing(kinds) Then kinds = InferKinds(data) + InsertSelectValues = "INSERT INTO " & fq & " (" & ColumnList(data, dialect) & ")" & vbCrLf & _ + ValuesSelect(data, dialect, kinds, textLen) +End Function + +' DDL that creates only if it doesn't already exist, with inferred types. +Public Function CreateTableIfMissing(ByVal fq As String, ByVal data As Variant, ByVal dialect As SqlDialect, _ + Optional ByRef kinds As Variant, _ + Optional ByVal numScale As Long = 2, Optional ByVal textLen As Long = 100) As String + If IsMissing(kinds) Then kinds = InferKinds(data) + Dim cLo As Long, cHi As Long, j As Long, hdr As Long + hdr = LBound(data, 1) + cLo = LBound(data, 2): cHi = UBound(data, 2) + Dim parts() As String + ReDim parts(cLo To cHi) + For j = cLo To cHi + parts(j) = BracketId(CStr(data(hdr, j)), dialect) & " " & SqlType(kinds(j), dialect, numScale, textLen) + Next j + Dim ddl As String + ddl = Join(parts, ", ") + + If dialect = dlSqlServer Then + Dim plain As String + plain = Replace(Replace(fq, "[", ""), "]", "") + CreateTableIfMissing = "IF OBJECT_ID('" & plain & "','U') IS NULL CREATE TABLE " & fq & " (" & ddl & ")" + Else + CreateTableIfMissing = "CREATE TABLE IF NOT EXISTS " & fq & " (" & ddl & ")" + End If +End Function diff --git a/TBFCLoad.bas b/TBFCLoad.bas new file mode 100644 index 0000000..5afaed4 --- /dev/null +++ b/TBFCLoad.bas @@ -0,0 +1,93 @@ +Attribute VB_Name = "TBFCLoad" +'========================================================================== +' TBFCLoad - load the "Upload" tab into fanalysis.GS.TBFC on SQL Server. +' +' Entry point: Load_TBFC (assign this to a button) +' +' A thin caller over the generic Sql (text generation) and Db (connection) +' objects. The forecast-specific choices live here; everything reusable lives +' in Sql/Db: +' - types inferred from the Excel cells (Sql.InferKinds): Date->DATE, +' numeric->DECIMAL(18,2), text->NVARCHAR(100) +' - auto-create fanalysis.GS.TBFC on first run +' - replace-by-Source: delete rows for the Source(s) on the tab, then insert +' - one T-SQL batch over the SQLOLEDB connection (zero install, integrated auth) +'========================================================================== + +Option Explicit + +Private Const SHEET_NAME As String = "Upload" +Private Const HDR_ROW As Long = 8 +Private Const FIRST_COL As Long = 2 ' B +Private Const LAST_COL As Long = 11 ' K +Private Const FQ As String = "[fanalysis].[GS].[TBFC]" +Private Const SVR As String = "USMIDSQL01" +Private Const DBNAME As String = "fanalysis" +Private Const AMT_SCALE As Long = 2 +Private Const TEXT_LEN As Long = 100 + + +Sub Load_TBFC() + + Dim ws As Worksheet + Dim arr As Variant + Dim kinds As Variant + Dim lastRow As Long + Dim srcCol As Long + Dim srcList As String + Dim batch As String + Dim db As Db + + '-- locate the tab -------------------------------------------------- + On Error Resume Next + Set ws = ThisWorkbook.Worksheets(SHEET_NAME) + On Error GoTo 0 + If ws Is Nothing Then + MsgBox "Sheet '" & SHEET_NAME & "' not found in this workbook.", vbExclamation + Exit Sub + End If + + lastRow = ws.Cells(ws.Rows.Count, FIRST_COL).End(xlUp).Row + If lastRow <= HDR_ROW Then + MsgBox "No data rows found below the header on '" & SHEET_NAME & "'.", vbExclamation + Exit Sub + End If + + '-- read header + data (typed Variant array; row 1 = header) -------- + arr = ws.Range(ws.Cells(HDR_ROW, FIRST_COL), ws.Cells(lastRow, LAST_COL)).Value + kinds = Sql.InferKinds(arr) + + srcCol = Sql.ColIndex(arr, "Source") + If srcCol = 0 Then + MsgBox "No 'Source' column found on the Upload tab; cannot replace by Source.", vbExclamation + Exit Sub + End If + srcList = Sql.InList(Sql.DistinctText(arr, srcCol), dlSqlServer) + + '-- compose the batch ---------------------------------------------- + batch = "SET NOCOUNT ON;" & vbCrLf + batch = batch & Sql.CreateTableIfMissing(FQ, arr, dlSqlServer, kinds, AMT_SCALE, TEXT_LEN) & ";" & vbCrLf + If Len(srcList) > 0 Then + batch = batch & "DELETE FROM " & FQ & " WHERE " & _ + Sql.BracketId("Source", dlSqlServer) & " IN (" & srcList & ");" & vbCrLf + End If + batch = batch & Sql.InsertSelectValues(FQ, arr, dlSqlServer, kinds, TEXT_LEN) & ";" + + '-- execute --------------------------------------------------------- + Set db = New Db + If Not db.OpenSqlServer(SVR, DBNAME) Then + MsgBox "Connection FAILED:" & vbCrLf & vbCrLf & db.LastError, vbExclamation + Exit Sub + End If + + If db.Exec(batch) Then + MsgBox "TBFC load complete." & vbCrLf & _ + "Inserted " & (UBound(arr, 1) - 1) & " row(s)." & vbCrLf & _ + "Source(s) replaced: " & srcList, vbInformation + Else + MsgBox "TBFC load FAILED:" & vbCrLf & vbCrLf & db.LastError, vbExclamation + End If + + db.CloseConn + +End Sub diff --git a/load_tbfc.py b/load_tbfc.py new file mode 100644 index 0000000..056c661 --- /dev/null +++ b/load_tbfc.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Load the "Upload" tab of a forecast workbook into fanalysis.GS.TBFC (SQL Server). + +Usage: python load_tbfc.py "" + +- Reads the saved workbook from disk (Excel need not be open). +- Infers a SQL type per column from the Excel cell types: + date cells -> DATE + numeric cells -> DECIMAL(18,2) (rounded half-up) + else (text) -> NVARCHAR(100) +- Creates fanalysis.GS.TBFC on first run if it doesn't exist. +- Replace-by-Source: deletes existing rows whose [Source] matches the + Source value(s) in this upload, then inserts the new rows. Other + scenarios already in the table are left untouched. +- Integrated (Windows) auth to USMIDSQL01; the whole load is one transaction. +""" + +import sys +import datetime +from decimal import Decimal, ROUND_HALF_UP + +import openpyxl +import pyodbc + +SERVER, DATABASE, SCHEMA, TABLE = "USMIDSQL01", "fanalysis", "GS", "TBFC" +SHEET = "Upload" +HEADER_ROW = 8 # data starts on HEADER_ROW + 1 +FIRST_COL = 2 # column B +LAST_COL = 11 # column K +NVARCHAR_LEN = 100 +AMOUNT_DP = 2 # decimal places for numeric columns + +FQ_TABLE = f"[{DATABASE}].[{SCHEMA}].[{TABLE}]" + + +def pick_driver(): + prefer = ["ODBC Driver 18 for SQL Server", "ODBC Driver 17 for SQL Server", + "SQL Server Native Client 11.0", "SQL Server"] + avail = pyodbc.drivers() + for d in prefer: + if d in avail: + return d + if avail: + return avail[-1] + raise RuntimeError("No ODBC driver for SQL Server found.") + + +def connect(): + driver = pick_driver() + cs = f"Driver={{{driver}}};Server={SERVER};Database={DATABASE};Trusted_Connection=yes;" + if "18" in driver: # driver 18 encrypts by default + cs += "Encrypt=no;TrustServerCertificate=yes;" + return pyodbc.connect(cs, autocommit=False) + + +def read_upload(path): + wb = openpyxl.load_workbook(path, read_only=True, data_only=True) + if SHEET not in wb.sheetnames: + raise RuntimeError(f'Sheet "{SHEET}" not found. Tabs: {wb.sheetnames}') + ws = wb[SHEET] + headers = [ws.cell(HEADER_ROW, c).value for c in range(FIRST_COL, LAST_COL + 1)] + rows = [] + for r in range(HEADER_ROW + 1, ws.max_row + 1): + vals = [ws.cell(r, c).value for c in range(FIRST_COL, LAST_COL + 1)] + if all(v is None or (isinstance(v, str) and not v.strip()) for v in vals): + continue # skip blank rows + rows.append(vals) + wb.close() + return headers, rows + + +def infer_types(headers, rows): + """Return [(sql_type, kind)] per column; kind in {date, num, text}.""" + out = [] + for j in range(len(headers)): + vals = [row[j] for row in rows if row[j] is not None] + if vals and all(isinstance(v, (datetime.datetime, datetime.date)) for v in vals): + out.append(("DATE", "date")) + elif vals and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in vals): + out.append((f"DECIMAL(18,{AMOUNT_DP})", "num")) + else: + out.append((f"NVARCHAR({NVARCHAR_LEN})", "text")) + return out + + +def coerce(value, kind): + if value is None: + return None + if kind == "date": + return value.date() if isinstance(value, datetime.datetime) else value + if kind == "num": + return Decimal(str(value)).quantize(Decimal(10) ** -AMOUNT_DP, rounding=ROUND_HALF_UP) + return str(value).strip()[:NVARCHAR_LEN] + + +def main(): + if len(sys.argv) < 2: + print("usage: python load_tbfc.py ", file=sys.stderr) + return 2 + path = sys.argv[1] + + headers, rows = read_upload(path) + if not rows: + print("No data rows found on the Upload tab.", file=sys.stderr) + return 1 + types = infer_types(headers, rows) + + if "Source" not in headers: + raise RuntimeError(f'No "Source" column found. Headers: {headers}') + src_idx = headers.index("Source") + + data = [[coerce(v, types[j][1]) for j, v in enumerate(row)] for row in rows] + sources = sorted({row[src_idx] for row in data if row[src_idx] is not None}) + + cols_ddl = ",\n ".join(f"[{h}] {t}" for h, (t, _) in zip(headers, types)) + create_sql = (f"IF OBJECT_ID('{DATABASE}.{SCHEMA}.{TABLE}','U') IS NULL\n" + f"CREATE TABLE {FQ_TABLE} (\n {cols_ddl}\n);") + col_list = ", ".join(f"[{h}]" for h in headers) + insert_sql = f"INSERT INTO {FQ_TABLE} ({col_list}) VALUES ({', '.join('?' for _ in headers)})" + + cn = connect() + cur = cn.cursor() + try: + cur.execute(create_sql) + deleted = 0 + if sources: + marks = ", ".join("?" for _ in sources) + cur.execute(f"DELETE FROM {FQ_TABLE} WHERE [Source] IN ({marks})", *sources) + deleted = cur.rowcount + cur.fast_executemany = True + cur.executemany(insert_sql, data) + cn.commit() + except Exception: + cn.rollback() + raise + finally: + cur.close() + cn.close() + + print("TBFC load complete.") + print(f" Source(s): {', '.join(sources)}") + print(f" Deleted: {deleted} existing row(s)") + print(f" Inserted: {len(data)} row(s)") + print(" Schema: " + ", ".join(f"{h} {t}" for h, (t, _) in zip(headers, types))) + return 0 + + +if __name__ == "__main__": + sys.exit(main())