arc/extraScripts/migration/holoo2hesabix/Services/E2eOpeningRunner.vb
Babak Alizadeh a5875e7a3a Release Holoo2Hesabix 1.1.0 with production-ready accounting migration.
Harden Holoo-to-Hesabix document partition/mapping, sample E2E tooling, remove hardcoded SQL credentials from scripts, fix income AR account 10401, and publish Setup MSI 1.1.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 15:56:40 +03:30

316 lines
16 KiB
VB.net

Imports System.IO
Imports System.Threading
Imports Newtonsoft.Json
''' <summary>
''' اجرای بدون UI: اطلاعات پایه + سال مالی/افتتاحیه روی کسب‌وکار تست.
''' Usage: Holoo2Hesabix.exe --e2e-opening --api-key KEY [--business-id N | --create-business NAME]
''' </summary>
Friend NotInheritable Class E2eOpeningRunner
Private Sub New()
End Sub
Public Shared Function Run(args As String()) As Integer
Dim apiKey = Arg(args, "--api-key")
Dim businessId = ParseInt(Arg(args, "--business-id"))
Dim createName = Arg(args, "--create-business")
Dim docsOnly = args.Any(Function(a) String.Equals(a, "--docs-only", StringComparison.OrdinalIgnoreCase))
Dim withManual = args.Any(Function(a) String.Equals(a, "--with-manual", StringComparison.OrdinalIgnoreCase))
Dim withInvoices = args.Any(Function(a) String.Equals(a, "--with-invoices", StringComparison.OrdinalIgnoreCase))
Dim withAllDocs = args.Any(Function(a) String.Equals(a, "--with-all-docs", StringComparison.OrdinalIgnoreCase))
Dim sampleLimit = ParseInt(Arg(args, "--sample"))
Dim sqlServer = If(Arg(args, "--sql-server"), "localhost")
Dim sqlUser = If(Arg(args, "--sql-user"), "sa")
Dim sqlPass = If(Arg(args, "--sql-pass"), Environment.GetEnvironmentVariable("HOLOO_SQL_PASS"))
If String.IsNullOrWhiteSpace(sqlPass) Then sqlPass = ""
Dim sqlDb = If(Arg(args, "--sql-db"), "Holoo1")
Dim baseUrl = If(Arg(args, "--api-base"), MigrationSession.DefaultApiBaseUrl)
Dim outDir = Arg(args, "--out")
If String.IsNullOrWhiteSpace(outDir) Then
outDir = "d:\hesabixArc\.eyeban"
End If
outDir = Path.GetFullPath(outDir)
Directory.CreateDirectory(outDir)
If String.IsNullOrWhiteSpace(apiKey) Then
Console.Error.WriteLine("ERROR: --api-key required")
Return 2
End If
If businessId <= 0 AndAlso String.IsNullOrWhiteSpace(createName) Then
createName = "holoo-e2e-opening-" & DateTime.Now.ToString("yyyyMMdd-HHmmss")
End If
Dim logPath = Path.Combine(outDir, "e2e-opening-log.txt")
Dim resultPath = Path.Combine(outDir, "e2e-opening-result.json")
File.WriteAllText(logPath, "")
Dim log As Action(Of String) =
Sub(msg)
Dim line = DateTime.Now.ToString("s") & " " & msg
Console.WriteLine(line)
File.AppendAllText(logPath, line & Environment.NewLine)
End Sub
Try
Return RunCore(apiKey, businessId, createName, docsOnly, withManual, withInvoices, withAllDocs, sampleLimit, sqlServer, sqlUser, sqlPass, sqlDb, baseUrl, resultPath, log).GetAwaiter().GetResult()
Catch ex As Exception
log("FATAL: " & ex.ToString())
File.WriteAllText(resultPath, JsonConvert.SerializeObject(New With {
.success = False,
.error = ex.Message,
.stack = ex.ToString()
}, Formatting.Indented))
Return 1
End Try
End Function
Private Shared Async Function RunCore(
apiKey As String,
businessId As Integer,
createName As String,
docsOnly As Boolean,
withManual As Boolean,
withInvoices As Boolean,
withAllDocs As Boolean,
sampleLimit As Integer,
sqlServer As String,
sqlUser As String,
sqlPass As String,
sqlDb As String,
baseUrl As String,
resultPath As String,
log As Action(Of String)
) As Task(Of Integer)
Dim api As New HesabixApiClient()
api.Configure(baseUrl, apiKey)
Dim currentUser = Await api.GetMeAsync().ConfigureAwait(False)
log("auth ok user=" & currentUser.DisplayName)
Dim session As New MigrationSession With {
.ApiBaseUrl = baseUrl,
.ApiKey = apiKey,
.CurrentUser = currentUser,
.SqlSettings = New SqlConnectionSettings With {
.Server = sqlServer,
.UserName = sqlUser,
.Password = sqlPass,
.Database = sqlDb,
.TrustServerCertificate = True
},
.SelectedDatabase = sqlDb,
.IsSqlConnected = True
}
Dim sql As New HolooSqlService()
Dim probe = Await sql.ProbeDatabaseAsync(session.SqlSettings, sqlDb).ConfigureAwait(False)
If Not probe.LooksLikeHoloo Then
Throw New InvalidOperationException("Holoo SQL probe failed: " & probe.SummaryText)
End If
session.HolooProbe = probe
log("sql ok db=" & sqlDb & " tables=" & probe.TableCount.ToString())
Dim currencyId As Integer
If businessId <= 0 Then
Dim currencies = Await api.ListCurrenciesAsync().ConfigureAwait(False)
Dim irr = currencies.FirstOrDefault(Function(c) String.Equals(c.Code, "IRR", StringComparison.OrdinalIgnoreCase) OrElse
(c.Title IsNot Nothing AndAlso c.Title.Contains("ریال")))
If irr Is Nothing Then irr = currencies.FirstOrDefault()
If irr Is Nothing Then Throw New InvalidOperationException("No currency available")
Dim req As New NewBusinessRequest With {
.Name = createName,
.BusinessType = "شرکت",
.BusinessField = "بازرگانی",
.DefaultCurrencyId = irr.Id,
.IncludeSampleData = False
}
Dim biz = Await api.CreateBusinessAsync(req).ConfigureAwait(False)
businessId = biz.Id
currencyId = biz.DefaultCurrencyId
If currencyId <= 0 Then currencyId = irr.Id
session.SelectedBusiness = biz
log("created business id=" & businessId.ToString() & " name=" & biz.Name)
Else
Dim biz = Await api.GetBusinessDetailsAsync(businessId).ConfigureAwait(False)
session.SelectedBusiness = biz
currencyId = biz.DefaultCurrencyId
log("using business id=" & businessId.ToString() & " name=" & biz.Name)
End If
If currencyId <= 0 Then Throw New InvalidOperationException("Business currency id missing")
' پایه + افتتاحیه (+ اختیاری اسناد دستی)
Dim selected = MigrationModuleInfo.GetAllModules()
For Each m In selected
If withAllDocs Then
' انبار جداگانه است و اغلب permission inventory.write ندارد؛ برای E2E حسابداری پیش‌فرض خاموش
m.Selected = m.ModuleKey <> MigrationModule.WarehouseDocs
Else
m.Selected = MigrationModuleInfo.IsBaseModule(m.ModuleKey) OrElse
m.ModuleKey = MigrationModule.FiscalYearsAndOpening OrElse
(withManual AndAlso m.ModuleKey = MigrationModule.ManualJournals) OrElse
(withInvoices AndAlso (m.ModuleKey = MigrationModule.Invoices OrElse m.ModuleKey = MigrationModule.WarehouseDocs))
End If
Next
session.SelectedModules = selected
Dim profile As SarfaslProfile = Nothing
If docsOnly Then
' docs-only: اگر پروفایل قفل‌شده بدون critical داریم، از rebuild سخت‌گیرانه عبور نکن
Dim storeEarly As New SarfaslProfileStore(businessId, sqlDb)
Dim previous = storeEarly.Load()
If previous IsNot Nothing AndAlso previous.Locked AndAlso previous.CriticalUnmappedCount = 0 Then
' preflight را اجرا کن ولی اگر critical تازه ظاهر شد و قفل قبلی سالم است، قفل قبلی را نگه دار
Dim preflight = Await New PreflightService().BuildReportAsync(session, api).ConfigureAwait(False)
session.Preflight = preflight
session.AllowCurrencyMismatch = Not preflight.CurrencyMatched
Dim rebuilt = session.SarfaslProfile
If rebuilt IsNot Nothing AndAlso rebuilt.CriticalUnmappedCount = 0 Then
profile = rebuilt
Else
log("docs-only: keep locked profile (rebuilt critical=" &
If(rebuilt Is Nothing, "n/a", rebuilt.CriticalUnmappedCount.ToString()) & ")")
profile = previous
session.SarfaslProfile = previous
End If
Else
log("preflight...")
Dim preflight = Await New PreflightService().BuildReportAsync(session, api).ConfigureAwait(False)
session.Preflight = preflight
session.AllowCurrencyMismatch = Not preflight.CurrencyMatched
profile = session.SarfaslProfile
End If
Else
log("preflight...")
Dim preflight = Await New PreflightService().BuildReportAsync(session, api).ConfigureAwait(False)
session.Preflight = preflight
session.AllowCurrencyMismatch = Not preflight.CurrencyMatched
profile = session.SarfaslProfile
End If
If profile Is Nothing Then
Throw New InvalidOperationException("Sarfasl profile was not built")
End If
If profile.CriticalUnmappedCount > 0 Then
Throw New InvalidOperationException("Critical unmapped sarfasl: " & profile.CriticalUnmappedCount.ToString())
End If
Dim store As New SarfaslProfileStore(businessId, sqlDb)
store.LockAndSave(profile)
session.SarfaslProfileLocked = True
log("profile locked unmapped=" & profile.UnmappedCount.ToString() & " critical=0 path=" & store.FilePath)
Dim mods = selected.Where(Function(x) x.Selected AndAlso x.Enabled).Select(Function(x) x.ModuleKey).ToList()
Dim baseSvc As New BaseDataTransferService()
AddHandler baseSvc.ProgressChanged, Sub(s, e) log("BASE [" & e.ModuleTitle & "] " & e.Message &
If(e.IsError, " ERR", ""))
Dim docSvc As New DocumentTransferService()
If sampleLimit > 0 Then
docSvc.SampleLimit = sampleLimit
log("sample-limit=" & sampleLimit.ToString() & " (per module)")
End If
AddHandler docSvc.ProgressChanged, Sub(s, e) log("DOC [" & e.ModuleTitle & "] " & e.Message &
If(e.IsError, " ERR", ""))
Dim cpPath As String
If docsOnly Then
log("docs-only: skip base transfer")
' فقط کلیدهای OB ناموفق را پاک کن تا تلاش مجدد شود؛ موفق‌ها دست نخورند
Dim storeCp As New CheckpointStore(businessId, sqlDb)
Dim cp0 = storeCp.LoadOrCreate(baseUrl, businessId, sqlServer, sqlDb)
Dim fy0 = cp0.EnsureModule(MigrationModule.FiscalYearsAndOpening.ToString())
Dim failKeys = fy0.Failed.Keys.Where(Function(k) k.StartsWith("OB:")).ToList()
For Each k In failKeys
fy0.Failed.Remove(k)
Next
storeCp.Save(cp0)
cpPath = storeCp.FilePath
Else
log("base transfer (skip person/product OB)...")
cpPath = Await baseSvc.RunAsync(session, api, mods, currencyId, resetCheckpoint:=True, skipOpeningBalances:=True).ConfigureAwait(False)
log("base done checkpoint=" & cpPath)
End If
log("opening transfer...")
cpPath = Await docSvc.RunAsync(session, api, mods, currencyId, resetCheckpoint:=False).ConfigureAwait(False)
log("docs done checkpoint=" & cpPath)
Dim cp = New CheckpointStore(businessId, sqlDb).LoadOrCreate(baseUrl, businessId, sqlServer, sqlDb)
Dim fyMod = cp.EnsureModule(MigrationModule.FiscalYearsAndOpening.ToString())
Dim obDone = fyMod.Done.Keys.Where(Function(k) k.StartsWith("OB:")).ToList()
Dim obFailed = fyMod.Failed.Where(Function(kv) kv.Key.StartsWith("OB:")).ToList()
Dim manMod = cp.EnsureModule(MigrationModule.ManualJournals.ToString())
Dim proofPath = ""
Try
Dim proof = New BalanceProofService().BuildHolooProof(session.SqlSettings, businessId)
proofPath = New BalanceProofService().SaveReport(proof, businessId, sqlDb)
Catch ex As Exception
log("proof warn: " & ex.Message)
End Try
Dim success = obDone.Count > 0 AndAlso obFailed.Count = 0
If withManual Then
success = success AndAlso manMod.Failed.Count = 0 AndAlso manMod.Done.Count > 0
End If
Dim invMod = cp.EnsureModule(MigrationModule.Invoices.ToString())
If withInvoices OrElse withAllDocs Then
success = success AndAlso invMod.Failed.Count = 0 AndAlso invMod.Done.Count > 0
End If
Dim rcpMod = cp.EnsureModule(MigrationModule.ReceiptsPayments.ToString())
Dim chkMod = cp.EnsureModule(MigrationModule.Checks.ToString())
Dim expMod = cp.EnsureModule(MigrationModule.ExpenseIncome.ToString())
If withAllDocs Then
success = success AndAlso rcpMod.Failed.Count = 0 AndAlso chkMod.Failed.Count = 0 AndAlso expMod.Failed.Count = 0 AndAlso manMod.Failed.Count = 0
success = success AndAlso (rcpMod.Done.Count > 0 OrElse chkMod.Done.Count > 0 OrElse expMod.Done.Count > 0 OrElse manMod.Done.Count > 0)
End If
Dim result = New With {
.success = success,
.businessId = businessId,
.businessName = session.SelectedBusiness.Name,
.currencyId = currencyId,
.openingDoneKeys = obDone,
.openingFailed = obFailed.ToDictionary(Function(kv) kv.Key, Function(kv) kv.Value),
.manualDone = manMod.Done.Count,
.manualFailed = manMod.Failed.Count,
.manualFailedSample = manMod.Failed.Take(10).ToDictionary(Function(kv) kv.Key, Function(kv) kv.Value),
.invoiceDone = invMod.Done.Count,
.invoiceFailed = invMod.Failed.Count,
.invoiceFailedSample = invMod.Failed.Take(15).ToDictionary(Function(kv) kv.Key, Function(kv) kv.Value),
.receiptsDone = rcpMod.Done.Count,
.receiptsFailed = rcpMod.Failed.Count,
.checksDone = chkMod.Done.Count,
.checksFailed = chkMod.Failed.Count,
.expenseDone = expMod.Done.Count,
.expenseFailed = expMod.Failed.Count,
.checkpointPath = cpPath,
.profilePath = store.FilePath,
.proofPath = proofPath,
.unmappedCount = profile.UnmappedCount,
.fiscalYears = If(session.Preflight Is Nothing, New List(Of Object)(),
session.Preflight.FiscalYears.Select(Function(y) New With {
.title = y.Title,
.hesabixId = y.HesabixId,
.start = y.StartDate.ToString("yyyy-MM-dd"),
.end = y.EndDate.ToString("yyyy-MM-dd")
}).Cast(Of Object)().ToList())
}
File.WriteAllText(resultPath, JsonConvert.SerializeObject(result, Formatting.Indented), Text.Encoding.UTF8)
log("RESULT success=" & success.ToString() & " written=" & resultPath)
Return If(success, 0, 1)
End Function
Private Shared Function Arg(args As String(), name As String) As String
For i = 0 To args.Length - 1
If String.Equals(args(i), name, StringComparison.OrdinalIgnoreCase) Then
If i + 1 < args.Length AndAlso Not args(i + 1).StartsWith("--") Then Return args(i + 1)
Return "1"
End If
If args(i).StartsWith(name & "=", StringComparison.OrdinalIgnoreCase) Then
Return args(i).Substring(name.Length + 1)
End If
Next
Return Nothing
End Function
Private Shared Function ParseInt(s As String) As Integer
Dim n As Integer
If Integer.TryParse(If(s, ""), n) Then Return n
Return 0
End Function
End Class