PAYATHON 2026

wpf 杂货店计费系统中的多单位金额计算

支付老李

结论

牙膏、食用油和面粉不需要各写一套金额公式,统一使用:

amount = quantity × unitPrice

前提是 quantity 和 unitPrice 使用同一个计价单位。

例如:

  • 牙膏:2 pc × 8.50 元/pc = 17.00 元
  • 食用油:1.5 litre × 12.00 元/litre = 18.00 元
  • 面粉:先将 750 g 换算为 0.75 kg,再计算 0.75 kg × 6.00 元/kg = 4.50 元

单位换算也不必散落在业务代码的各种 if/else 中。可以采用“基础单位 + 换算系数”的方式统一处理,并将换算关系保存在数据库中。

统一计算方式

先为每种计量维度指定一个基础单位:

计量维度基础单位可选销售单位
数量pcbox、pack
质量kgg
容量litreml

每个单位保存一个换算系数,含义是:

1 当前单位 = conversionFactor × 基础单位

例如:

1 g     = 0.001 kg
1 kg    = 1 kg
1 ml    = 0.001 litre
1 litre = 1 litre
1 box   = 12 pc

销售数量换算成库存基础单位的公式是:

baseQuantity = saleQuantity × conversionFactor

如果单价也按基础单位定义,则金额为:

amount = baseQuantity × baseUnitPrice

例如,食用油的库存单位和计价单位都是 litre,顾客购买 500 ml:

baseQuantity = 500 × 0.001 = 0.5 litre
amount = 0.5 × 12.00 = 6.00

推荐的数据库结构

计量维度表

这张表用于区分不同的计量维度,防止质量单位与容量单位发生错误换算。

CREATE TABLE MeasurementDimension (
    DimensionId INT PRIMARY KEY,
    Name NVARCHAR(50) NOT NULL
);

示例数据:

1  Quantity
2  Mass
3  Volume

单位表

CREATE TABLE Unit (
    UnitId INT PRIMARY KEY,
    DimensionId INT NOT NULL,
    Name NVARCHAR(50) NOT NULL,
    Symbol NVARCHAR(20) NOT NULL,
    ConversionToBase DECIMAL(18, 6) NOT NULL,
    IsBaseUnit BIT NOT NULL,
    FOREIGN KEY (DimensionId)
        REFERENCES MeasurementDimension(DimensionId)
);

示例数据:

UnitIdDimensionSymbolConversionToBaseIsBaseUnit
1Quantitypc11
2Masskg11
3Massg0.0010
4Volumelitre11
5Volumeml0.0010

box、pack 等包装单位通常没有全局固定的换算关系。一箱牙膏可能装 12 件,一箱饮料却可能装 24 瓶。因此,这类换算关系更适合保存在商品单位表中,而不是直接写入全局单位表。

商品表

CREATE TABLE Product (
    ProductId INT PRIMARY KEY,
    ProductName NVARCHAR(200) NOT NULL,
    BaseUnitId INT NOT NULL,
    FOREIGN KEY (BaseUnitId) REFERENCES Unit(UnitId)
);

示例:

ProductBaseUnit
牙膏pc
食用油litre
面粉kg

商品可用单位表

该表用于定义某个商品允许使用哪些采购或销售单位,以及这些单位与商品基础单位之间的换算关系。

CREATE TABLE ProductUnit (
    ProductUnitId INT PRIMARY KEY,
    ProductId INT NOT NULL,
    UnitId INT NOT NULL,
    ConversionToProductBase DECIMAL(18, 6) NOT NULL,
    FOREIGN KEY (ProductId) REFERENCES Product(ProductId),
    FOREIGN KEY (UnitId) REFERENCES Unit(UnitId),
    CONSTRAINT UQ_ProductUnit UNIQUE (ProductId, UnitId)
);

例如,一箱牙膏包含 12 件:

Product: 牙膏
Unit: box
ConversionToProductBase: 12

这样既能表示商品特有的包装关系,也不会把所有 box 都错误地解释为 12 件。

商品价格表

价格必须明确绑定计价单位,不能只在商品表中保存一个含义不清的 Price。

CREATE TABLE ProductPrice (
    ProductPriceId INT PRIMARY KEY,
    ProductId INT NOT NULL,
    ProductUnitId INT NOT NULL,
    UnitPrice DECIMAL(18, 4) NOT NULL,
    EffectiveFrom DATETIME2 NOT NULL,
    EffectiveTo DATETIME2 NULL,
    FOREIGN KEY (ProductId) REFERENCES Product(ProductId),
    FOREIGN KEY (ProductUnitId) REFERENCES ProductUnit(ProductUnitId)
);

如果系统只允许按基础单位计价,可以简化设计,直接在商品表中保存 BaseUnitPrice。如果“按箱”和“按件”的价格可以分别设置,就应保留独立的价格表,因为箱价不一定等于件价乘以每箱数量。

库存流水表

不建议只保存一个可随时修改的当前库存值。更稳妥的做法是记录库存流水,并统一使用商品的基础单位。

CREATE TABLE StockTransaction (
    StockTransactionId BIGINT PRIMARY KEY,
    ProductId INT NOT NULL,
    TransactionType NVARCHAR(20) NOT NULL,
    QuantityInBaseUnit DECIMAL(18, 6) NOT NULL,
    TransactionTime DATETIME2 NOT NULL,
    ReferenceType NVARCHAR(30) NULL,
    ReferenceId BIGINT NULL,
    FOREIGN KEY (ProductId) REFERENCES Product(ProductId)
);

数值正负可以约定为:

  • 期初库存、采购和退货入库使用正数
  • 销售、损耗和退货出库使用负数

当前库存可以这样计算:

SELECT
    ProductId,
    SUM(QuantityInBaseUnit) AS CurrentStock
FROM StockTransaction
GROUP BY ProductId;

数据量较大时,可以另建库存汇总表,但仍应保留库存流水,方便审计和重新计算。

销售明细表

CREATE TABLE SaleLine (
    SaleLineId BIGINT PRIMARY KEY,
    SaleId BIGINT NOT NULL,
    ProductId INT NOT NULL,
    ProductUnitId INT NOT NULL,
    Quantity DECIMAL(18, 6) NOT NULL,
    ConversionToBase DECIMAL(18, 6) NOT NULL,
    QuantityInBaseUnit DECIMAL(18, 6) NOT NULL,
    UnitPrice DECIMAL(18, 4) NOT NULL,
    LineAmount DECIMAL(18, 2) NOT NULL,
    FOREIGN KEY (ProductId) REFERENCES Product(ProductId),
    FOREIGN KEY (ProductUnitId) REFERENCES ProductUnit(ProductUnitId)
);

销售发生时,需要把当时使用的换算系数、单价和金额一并保存到销售明细中,不能在以后根据商品的当前价格反推。即使商品价格或包装规格发生变化,历史账单也应保持不变。

C# 计算示例

下面的示例假设销售单价按顾客选择的销售单位定义:

public static decimal CalculateLineAmount(
    decimal quantity,
    decimal unitPrice)
{
    if (quantity <= 0)
        throw new ArgumentOutOfRangeException(nameof(quantity));

    if (unitPrice < 0)
        throw new ArgumentOutOfRangeException(nameof(unitPrice));

    return decimal.Round(
        quantity * unitPrice,
        2,
        MidpointRounding.AwayFromZero);
}

调用方式:

decimal toothpasteAmount = CalculateLineAmount(2m, 8.50m);
decimal oilAmount = CalculateLineAmount(0.5m, 12.00m);
decimal flourAmount = CalculateLineAmount(0.75m, 6.00m);

如果用户录入的单位与计价单位不同,需要先换算单位:

public static decimal ConvertQuantity(
    decimal quantity,
    decimal fromUnitToBaseFactor,
    decimal priceUnitToBaseFactor)
{
    if (quantity <= 0)
        throw new ArgumentOutOfRangeException(nameof(quantity));

    if (fromUnitToBaseFactor <= 0)
        throw new ArgumentOutOfRangeException(nameof(fromUnitToBaseFactor));

    if (priceUnitToBaseFactor <= 0)
        throw new ArgumentOutOfRangeException(nameof(priceUnitToBaseFactor));

    decimal quantityInBaseUnit = quantity * fromUnitToBaseFactor;

    return quantityInBaseUnit / priceUnitToBaseFactor;
}

public static decimal CalculateAmount(
    decimal saleQuantity,
    decimal saleUnitToBaseFactor,
    decimal priceUnitToBaseFactor,
    decimal unitPrice)
{
    decimal billableQuantity = ConvertQuantity(
        saleQuantity,
        saleUnitToBaseFactor,
        priceUnitToBaseFactor);

    return decimal.Round(
        billableQuantity * unitPrice,
        2,
        MidpointRounding.AwayFromZero);
}

例如,顾客购买 500 ml 食用油,价格为 12 元/litre:

decimal amount = CalculateAmount(
    saleQuantity: 500m,
    saleUnitToBaseFactor: 0.001m, // ml -> litre
    priceUnitToBaseFactor: 1m,    // litre -> litre
    unitPrice: 12m);

// amount = 6.00

一箱牙膏包含 12 件,按件计价,顾客购买 2 箱:

decimal amount = CalculateAmount(
    saleQuantity: 2m,
    saleUnitToBaseFactor: 12m, // box -> pc
    priceUnitToBaseFactor: 1m, // pc -> pc
    unitPrice: 8.50m);

// amount = 204.00

WPF 层的职责

WPF 界面负责以下操作:

  • 选择商品和销售单位
  • 输入销售数量
  • 显示单价、换算后的数量和金额
  • 提交销售请求

单位换算、金额计算和库存扣减应该放在业务服务中,不要写进按钮事件或 TextChanged 事件。这样,退货、报价单、采购和库存调整等功能也能复用同一套逻辑。

金额和数量都应使用 decimal,不要使用 float 或 double:

public sealed class SaleCalculator
{
    public decimal Calculate(
        decimal quantity,
        decimal saleUnitFactor,
        decimal priceUnitFactor,
        decimal unitPrice)
    {
        decimal quantityInBaseUnit = quantity * saleUnitFactor;
        decimal billableQuantity = quantityInBaseUnit / priceUnitFactor;

        return decimal.Round(
            billableQuantity * unitPrice,
            2,
            MidpointRounding.AwayFromZero);
    }
}

注意事项

不同计量维度不能互相转换

kg 可以换算为 g,litre 可以换算为 ml,但 kg 不能直接通用地换算为 litre。质量与容量之间的换算需要商品密度等额外信息,而且不同商品的密度并不相同。

系统必须校验销售单位与商品基础单位是否属于同一个计量维度。除非业务中明确配置了某个商品专用的跨维度换算关系,否则应拒绝换算。

包装单位应按商品配置

box、bag、bottle 本身不能说明具体数量:

1 box 牙膏 = 12 pc
1 box 饮料 = 24 bottle
1 bag 面粉 = 5 kg

这些关系应保存在 ProductUnit 中。

区分计量单位和商品规格

“1 litre 食用油”和“900 ml 食用油”可能是两个不同的 SKU,并不一定只是同一商品采用了不同的销售数量。如果它们有不同的条码、进价、售价或包装,就应该建成两个商品或 SKU,各自使用 bottle 作为库存单位,并另外记录净含量。

如果是按升销售的散装油,则适合直接将 litre 设为库存基础单位。

明确舍入规则

数量精度与货币精度需要分别配置。例如:

  • 库存数量保存 3~6 位小数
  • 单价保存 4 位或更多小数
  • 销售行金额按财务规则舍入到 2 位小数
  • 订单总额由已经舍入的明细金额求和,避免界面与数据库结果不一致

至于使用 MidpointRounding.AwayFromZero、ToEven 还是其他规则,应根据当地财务和税务要求确定。

销售与扣减库存应在同一事务中完成

保存销售单、销售明细和库存流水时,应使用同一个数据库事务。库存不足检查还需要处理并发销售,不能只依赖 WPF 界面提前读取的库存值。

整个模型可以归纳为:

销售输入数量
    × 销售单位到基础单位的换算系数
    = 库存扣减数量

销售输入数量
    → 换算为计价单位数量
    × 该计价单位的单价
    = 销售金额

按这种方式设计后,件、千克、克、升、毫升、箱和袋等单位都可以使用同一套计算逻辑。新增单位时,通常只需增加数据库配置,无须修改金额计算公式。

备注:内容仅供参考。