Excel VBA Billing UserForm 如何写入账单日期与金额
支付阿杰
结论
不要用 Select、Selection.End(xlDown) 或没有边界限制的 Offset 来确定写入位置。可以按下面的方式处理:
- 在 D 列查找
BillingDate所在的行。如果找不到,就追加到第 47 行之后。 - 对于普通 billing cycle,从 D 列开始向左检查,在同一行找到最近的空单元格,并写入 A1~A19 所选项目的合计。
- 对于第一个 billing cycle,从 D 列开始向右检查,在同一行找到最近的空单元格,并写入
Grand Total Engagement。 - 所有
Range、Cells、Rows和Columns都要明确指定为Main工作表中的对象。
下面的代码可以直接放进 UserForm 的提交按钮事件中,再根据实际的控件名称和单元格地址进行调整。
完整代码示例
这段代码基于以下设置:
- Billing Date 保存在 D 列。
- 明细表从第 47 行开始。
CmBBill = "Yes"表示第一个 billing cycle。- UserForm 上的项目复选框名为
A1~A19。 - 每个复选框的
Tag属性中保存对应金额的单元格地址,例如E12。 - 工作表中已经定义名称
GrandTotalEngagement,并指向 Grand Total Engagement 的金额单元格。 - 第 5 行是右侧 billing 项目的表头,用于确定允许写入的最右列。
Option Explicit
Private Const FIRST_DATA_ROW As Long = 47
Private Const DATE_COLUMN As Long = 4 'Column D
Private Const FIRST_LEFT_COLUMN As Long = 1 'Column A
Private Const RIGHT_HEADER_ROW As Long = 5
Private Sub cmdSubmit_Click()
Dim ws As Worksheet
Dim billDateValue As Date
Dim billingRow As Long
Dim lastRightColumn As Long
Dim billingAmount As Currency
Dim targetCell As Range
Set ws = Main
If Not IsDate(Me.BillingDate.Value) Then
MsgBox "请输入有效的 Billing Date。", vbExclamation
Exit Sub
End If
'DateValue removes any time portion.
billDateValue = DateValue(CDate(Me.BillingDate.Value))
'Find the existing date row, or append a new row.
billingRow = GetBillingRow(ws, billDateValue)
If Len(ws.Cells(billingRow, DATE_COLUMN).Value2) = 0 Then
ws.Cells(billingRow, DATE_COLUMN).Value = billDateValue
ws.Cells(billingRow, DATE_COLUMN).NumberFormat = "yyyy-mm-dd"
End If
If StrComp(Trim$(Me.CmBBill.Value), "Yes", vbTextCompare) = 0 Then
'First billing cycle: use Grand Total Engagement.
If Not IsNumeric(ws.Range("GrandTotalEngagement").Value2) Then
MsgBox "Grand Total Engagement 不是有效金额。", vbExclamation
Exit Sub
End If
billingAmount = CCur(ws.Range("GrandTotalEngagement").Value2)
'Find the last available billing column from the header row.
lastRightColumn = ws.Cells(RIGHT_HEADER_ROW, _
ws.Columns.Count).End(xlToLeft).Column
If lastRightColumn <= DATE_COLUMN Then
MsgBox "D 列右侧没有配置可写入的 billing 列。", vbExclamation
Exit Sub
End If
Set targetCell = FindNearestBlank( _
ws:=ws, _
rowNumber:=billingRow, _
firstColumn:=DATE_COLUMN + 1, _
lastColumn:=lastRightColumn, _
stepValue:=1)
Else
'Other billing cycles: sum the selected A1-A19 projects.
billingAmount = GetSelectedProjectTotal(ws)
Set targetCell = FindNearestBlank( _
ws:=ws, _
rowNumber:=billingRow, _
firstColumn:=DATE_COLUMN - 1, _
lastColumn:=FIRST_LEFT_COLUMN, _
stepValue:=-1)
End If
If targetCell Is Nothing Then
MsgBox "Billing Date 对应行中没有可用的空单元格。", vbExclamation
Exit Sub
End If
targetCell.Value = billingAmount
targetCell.NumberFormat = "$#,##0.00"
MsgBox "Billing 信息已写入 " & targetCell.Address(False, False) & "。", _
vbInformation
End Sub
查找或创建 Billing Date 行
下面的函数从第 47 行开始检查 D 列。比较日期时使用 DateValue,这样即使单元格中带有时间,也能正确匹配同一天。
Private Function GetBillingRow( _
ByVal ws As Worksheet, _
ByVal billDateValue As Date) As Long
Dim lastRow As Long
Dim rowNumber As Long
lastRow = ws.Cells(ws.Rows.Count, DATE_COLUMN).End(xlUp).Row
If lastRow < FIRST_DATA_ROW Then
GetBillingRow = FIRST_DATA_ROW
Exit Function
End If
For rowNumber = FIRST_DATA_ROW To lastRow
If IsDate(ws.Cells(rowNumber, DATE_COLUMN).Value) Then
If DateValue(CDate(ws.Cells(rowNumber, DATE_COLUMN).Value)) = _
DateValue(billDateValue) Then
GetBillingRow = rowNumber
Exit Function
End If
End If
Next rowNumber
GetBillingRow = lastRow + 1
End Function
因此不需要使用下面的写法:
Main.Range("D46").Select
LastRow = Selection.End(xlDown).Row
如果中间有空白行,End(xlDown) 可能会提前停止。如果下方没有数据,它还可能直接跳到工作表底部。从工作表最后一行向上查找通常更可靠。
查找左右最近的空单元格
向左和向右查找可以共用一个函数。向左查找时传入 stepValue:=-1,向右查找时传入 stepValue:=1。
Private Function FindNearestBlank( _
ByVal ws As Worksheet, _
ByVal rowNumber As Long, _
ByVal firstColumn As Long, _
ByVal lastColumn As Long, _
ByVal stepValue As Long) As Range
Dim columnNumber As Long
If stepValue <> 1 And stepValue <> -1 Then
Err.Raise 5, , "stepValue 必须是 1 或 -1。"
End If
For columnNumber = firstColumn To lastColumn Step stepValue
If Len(ws.Cells(rowNumber, columnNumber).Value2) = 0 Then
Set FindNearestBlank = ws.Cells(rowNumber, columnNumber)
Exit Function
End If
Next columnNumber
Set FindNearestBlank = Nothing
End Function
Len(cell.Value2) = 0 会同时把真正的空单元格和公式返回的空字符串 "" 视为空白。如果只允许写入完全没有内容和公式的单元格,应改成:
If IsEmpty(ws.Cells(rowNumber, columnNumber).Value) Then
计算 A1~A19 所选项目的合计
原问题没有说明 A1~A19 控件与金额单元格的具体对应关系。一个比较容易维护的办法,是把金额单元格地址保存在复选框的 Tag 属性中。
例如:
- 控件
A1的Tag设置为E12 - 控件
A2的Tag设置为E13 - 控件
A3的Tag设置为E14
计算函数如下:
Private Function GetSelectedProjectTotal( _
ByVal ws As Worksheet) As Currency
Dim index As Long
Dim projectControl As Object
Dim amountAddress As String
Dim amountValue As Variant
Dim total As Currency
For index = 1 To 19
Set projectControl = Me.Controls("A" & index)
If CBool(projectControl.Value) Then
amountAddress = Trim$(CStr(projectControl.Tag))
If Len(amountAddress) = 0 Then
Err.Raise vbObjectError + 1000, , _
projectControl.Name & " 未配置金额单元格地址。"
End If
amountValue = ws.Range(amountAddress).Value2
If Not IsNumeric(amountValue) Then
Err.Raise vbObjectError + 1001, , _
projectControl.Name & " 对应的金额不是数字。"
End If
total = total + CCur(amountValue)
End If
Next index
GetSelectedProjectTotal = total
End Function
如果实际的复选框名称是 chkA1~chkA19,只需把这一行:
Set projectControl = Me.Controls("A" & index)
改成:
Set projectControl = Me.Controls("chkA" & index)
如果金额地址没有保存在复选框的 Tag 中,就需要根据实际表格结构调整 amountAddress 的取值方式。
Grand Total Engagement 的引用方式
示例通过工作簿名称引用金额:
ws.Range("GrandTotalEngagement").Value2
可以在 Excel 的名称管理器中,将 GrandTotalEngagement 指向实际的总金额单元格。以后插入行或列时,代码通常不需要随之修改。
如果没有定义名称,也可以直接改成实际地址,例如:
billingAmount = CCur(ws.Range("H24").Value2)
具体地址应以实际工作表为准。
需要注意的地方
Cells和Range必须带有ws.或Main.前缀,否则默认作用于当前活动工作表。- 如果同一天允许写入多笔金额,代码会继续查找该方向上的下一个空单元格。
- 如果同一天只能有一笔记录,应在写入前检查目标日期是否已经存在金额,并提示用户确认。
Currency比Double更适合常规账单金额计算,可以减少二进制浮点误差。- 向左或向右搜索时都要设置明确的边界,避免将金额写入 billing table 之外的列。
- 如果表格中有合并单元格,建议先取消合并,否则“最近空单元格”的判断可能不可靠。
备注:内容仅供参考。
