import { pgTable, timestamp, varchar, integer, text, index, serial, jsonb, uniqueIndex, } from 'drizzle-orm/pg-core' import { user } from './auth-schema.ts' // --------------------------------------------------------------------------- // 充电桩 / Charge Point // OCPP 1.6-J Section 4.2 BootNotification // --------------------------------------------------------------------------- export const chargePoint = pgTable('charge_point', { /** 内部 UUID 主键 */ id: varchar('id').primaryKey(), /** * 充电桩唯一标识符(chargeBoxIdentity) * TLS/WebSocket 连接路径中的最后一段,CiString255Type */ chargePointIdentifier: varchar('charge_point_identifier', { length: 255, }) .unique() .notNull(), /** BootNotification.req: chargePointSerialNumber, CiString25Type */ chargePointSerialNumber: varchar('charge_point_serial_number', { length: 25, }), /** BootNotification.req: chargePointModel, CiString20Type (required) */ chargePointModel: varchar('charge_point_model', { length: 20 }).notNull(), /** BootNotification.req: chargePointVendor, CiString20Type (required) */ chargePointVendor: varchar('charge_point_vendor', { length: 20 }).notNull(), /** BootNotification.req: firmwareVersion, CiString50Type */ firmwareVersion: varchar('firmware_version', { length: 50 }), /** BootNotification.req: iccid (SIM card), CiString20Type */ iccid: varchar('iccid', { length: 20 }), /** BootNotification.req: imsi, CiString20Type */ imsi: varchar('imsi', { length: 20 }), /** BootNotification.req: meterSerialNumber, CiString25Type */ meterSerialNumber: varchar('meter_serial_number', { length: 25 }), /** BootNotification.req: meterType, CiString25Type */ meterType: varchar('meter_type', { length: 25 }), /** * BootNotification.conf: status * Accepted = 正常运行,Pending = 等待配置,Rejected = 拒绝(不提供服务) */ registrationStatus: varchar('registration_status', { enum: ['Accepted', 'Pending', 'Rejected'], }) .notNull() .default('Pending'), /** * BootNotification.conf: heartbeatInterval(秒) * CSMS 通知充电桩应以此间隔发送心跳 */ heartbeatInterval: integer('heartbeat_interval').default(60), /** 最后一次收到 Heartbeat.req 的时间(UTC) */ lastHeartbeatAt: timestamp('last_heartbeat_at', { withTimezone: true }), /** 最后一次收到 BootNotification.req 的时间(UTC) */ lastBootNotificationAt: timestamp('last_boot_notification_at', { withTimezone: true, }), /** * 电价(单位:分/kWh,即 0.01 CNY/kWh) * 交易结束时按实际用电量从储值卡扣费:fee = ceil(energyWh * feePerKwh / 1000) * 默认为 0,即不计费 */ feePerKwh: integer('fee_per_kwh').notNull().default(0), createdAt: timestamp('created_at', { withTimezone: true }) .notNull() .defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }) .notNull() .defaultNow() .$onUpdate(() => new Date()), }) // --------------------------------------------------------------------------- // 连接器 / Connector // OCPP 1.6-J Section 4.6 StatusNotification // --------------------------------------------------------------------------- /** * 连接器 ID 规范(OCPP 1.6-J Section 2.2): * - connectorId = 0:主控制器(StatusNotification 中用于报告整桩状态) * - connectorId ≥ 1:实际插口,从 1 开始按顺序编号,不能跳号 */ export const connector = pgTable( 'connector', { id: varchar('id').primaryKey(), chargePointId: varchar('charge_point_id') .notNull() .references(() => chargePoint.id, { onDelete: 'cascade' }), /** OCPP connectorId(0 = 主控制器,≥1 = 实际插口) */ connectorId: integer('connector_id').notNull(), /** * 当前状态 — OCPP 1.6-J Section 7.7 ChargePointStatus * Available / Preparing / Charging / SuspendedEVSE / SuspendedEV / * Finishing / Reserved / Unavailable / Faulted */ status: varchar('status', { enum: [ 'Available', 'Preparing', 'Charging', 'SuspendedEVSE', 'SuspendedEV', 'Finishing', 'Reserved', 'Unavailable', 'Faulted', ], }) .notNull() .default('Unavailable'), /** 错误码 — OCPP 1.6-J Section 7.6 ChargePointErrorCode */ errorCode: varchar('error_code', { enum: [ 'NoError', 'ConnectorLockFailure', 'EVCommunicationError', 'GroundFailure', 'HighTemperature', 'InternalError', 'LocalListConflict', 'OtherError', 'OverCurrentFailure', 'OverVoltage', 'PowerMeterFailure', 'PowerSwitchFailure', 'ReaderFailure', 'ResetFailure', 'UnderVoltage', 'WeakSignal', ], }) .notNull() .default('NoError'), /** StatusNotification.req: info, CiString50Type */ info: varchar('info', { length: 50 }), /** StatusNotification.req: vendorId, CiString255Type */ vendorId: varchar('vendor_id', { length: 255 }), /** StatusNotification.req: vendorErrorCode, CiString50Type */ vendorErrorCode: varchar('vendor_error_code', { length: 50 }), /** StatusNotification.req: timestamp(充电桩报告的状态时间,UTC) */ lastStatusAt: timestamp('last_status_at', { withTimezone: true }) .notNull() .defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }) .notNull() .defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }) .notNull() .defaultNow() .$onUpdate(() => new Date()), }, (table) => ({ chargePointIdIdx: index('idx_connector_charge_point_id').on( table.chargePointId ), /** 同一充电桩内 connectorId 唯一 */ chargePointConnectorUniq: uniqueIndex( 'idx_connector_charge_point_connector' ).on(table.chargePointId, table.connectorId), }) ) // --------------------------------------------------------------------------- // 连接器状态历史 / Connector Status History // 完整记录每条 StatusNotification.req,用于审计与历史回溯 // --------------------------------------------------------------------------- export const connectorStatusHistory = pgTable( 'connector_status_history', { id: varchar('id').primaryKey(), connectorId: varchar('connector_id') .notNull() .references(() => connector.id, { onDelete: 'cascade' }), /** OCPP connectorId(冗余存储,方便直接查询) */ connectorNumber: integer('connector_number').notNull(), status: varchar('status', { enum: [ 'Available', 'Preparing', 'Charging', 'SuspendedEVSE', 'SuspendedEV', 'Finishing', 'Reserved', 'Unavailable', 'Faulted', ], }).notNull(), errorCode: varchar('error_code', { enum: [ 'NoError', 'ConnectorLockFailure', 'EVCommunicationError', 'GroundFailure', 'HighTemperature', 'InternalError', 'LocalListConflict', 'OtherError', 'OverCurrentFailure', 'OverVoltage', 'PowerMeterFailure', 'PowerSwitchFailure', 'ReaderFailure', 'ResetFailure', 'UnderVoltage', 'WeakSignal', ], }).notNull(), info: varchar('info', { length: 50 }), vendorId: varchar('vendor_id', { length: 255 }), vendorErrorCode: varchar('vendor_error_code', { length: 50 }), /** StatusNotification.req: timestamp(充电桩上报的状态时间) */ statusTimestamp: timestamp('status_timestamp', { withTimezone: true }), /** CSMS 收到消息的时间 */ receivedAt: timestamp('received_at', { withTimezone: true }) .notNull() .defaultNow(), }, (table) => ({ connectorIdIdx: index('idx_status_history_connector_id').on( table.connectorId ), statusTimestampIdx: index('idx_status_history_timestamp').on( table.statusTimestamp ), receivedAtIdx: index('idx_status_history_received_at').on( table.receivedAt ), }) ) // --------------------------------------------------------------------------- // 认证标签 / IdTag // OCPP 1.6-J Section 4.1 Authorize / Section 7.15 IdTagInfo // --------------------------------------------------------------------------- export const idTag = pgTable('id_tag', { /** * RFID 卡号或 App 生成的令牌,CiString20Type * 对应 OCPP Authorize.req.idTag */ idTag: varchar('id_tag', { length: 20 }).primaryKey(), /** * 父标签(用于分组授权),CiString20Type * 当 parentIdTag 在本地列表被接受时,子标签同样被接受 */ parentIdTag: varchar('parent_id_tag', { length: 20 }), /** * 标签当前状态 — OCPP 1.6-J Section 7.14 AuthorizationStatus * Accepted / Blocked / Expired / Invalid / ConcurrentTx */ status: varchar('status', { enum: ['Accepted', 'Blocked', 'Expired', 'Invalid', 'ConcurrentTx'], }) .notNull() .default('Accepted'), /** * 过期时间(UTC) * 若设置,CSMS 在 Authorize.conf 中会返回此字段供充电桩离线缓存使用 */ expiryDate: timestamp('expiry_date', { withTimezone: true }), /** * 关联的平台用户(可选) * 允许将 RFID 卡与注册用户绑定,支持 Web/App 远程查询充电记录 */ userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), /** * 储值卡余额(单位:分) * 以整数存储,1 分 = 0.01 CNY,前端显示时除以 100 */ balance: integer('balance').notNull().default(0), /** * 卡面内容排列方式 */ cardLayout: varchar('card_layout', { enum: ['center', 'around'] }).default('around'), /** * 卡底装饰风格 * 对应 faces/ 目录中已注册的卡面组件 */ cardSkin: varchar('card_skin', { enum: ['line', 'circles', 'glow', 'vip', 'redeye'] }).default('circles'), createdAt: timestamp('created_at', { withTimezone: true }) .notNull() .defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }) .notNull() .defaultNow() .$onUpdate(() => new Date()), }) // --------------------------------------------------------------------------- // 充电事务 / Transaction // OCPP 1.6-J Section 4.3 StartTransaction / Section 4.4 StopTransaction // --------------------------------------------------------------------------- export const transaction = pgTable( 'transaction', { /** * OCPP transactionId(整型,自增) * OCPP 1.6-J Section 4.3: "The transaction id MAY be a number ... it MUST be unique." * 使用 serial 保证全局唯一且自动递增 */ id: serial('id').primaryKey(), chargePointId: varchar('charge_point_id') .notNull() .references(() => chargePoint.id, { onDelete: 'cascade' }), connectorId: varchar('connector_id') .notNull() .references(() => connector.id, { onDelete: 'cascade' }), /** OCPP connectorId(冗余存储,方便直接查询) */ connectorNumber: integer('connector_number').notNull(), /** * 发起充电的 idTag,CiString20Type * StartTransaction.req.idTag */ idTag: varchar('id_tag', { length: 20 }).notNull(), /** * CSMS 对 idTag 的鉴权结果(StartTransaction.conf 中返回) * OCPP 1.6-J Section 7.14 AuthorizationStatus */ idTagStatus: varchar('id_tag_status', { enum: ['Accepted', 'Blocked', 'Expired', 'Invalid', 'ConcurrentTx'], }), /** * 充电开始时间(充电桩本地时间,UTC) * StartTransaction.req.timestamp */ startTimestamp: timestamp('start_timestamp', { withTimezone: true, }).notNull(), /** * 开始时的电表读数(Wh) * StartTransaction.req.meterStart */ startMeterValue: integer('start_meter_value').notNull(), /** * 停止充电的 idTag(可能与 idTag 不同,如工作人员强制停止) * StopTransaction.req.idTag(optional) */ stopIdTag: varchar('stop_id_tag', { length: 20 }), /** * 充电结束时间(UTC) * StopTransaction.req.timestamp */ stopTimestamp: timestamp('stop_timestamp', { withTimezone: true }), /** * 结束时的电表读数(Wh) * StopTransaction.req.meterStop */ stopMeterValue: integer('stop_meter_value'), /** * 停止原因 — OCPP 1.6-J Section 7.20 Reason * EmergencyStop / EVDisconnected / HardReset / Local / Other / * PowerLoss / Reboot / Remote / SoftReset / UnlockCommand / DeAuthorized */ stopReason: varchar('stop_reason', { enum: [ 'EmergencyStop', 'EVDisconnected', 'HardReset', 'Local', 'Other', 'PowerLoss', 'Reboot', 'Remote', 'SoftReset', 'UnlockCommand', 'DeAuthorized', ], }), /** * 本次充电扣费金额(单位:分) * 由 StopTransaction 处理时根据实际用电量和充电桩电价计算写入 * null 表示未计费(如免费充电桩或交易异常终止) */ chargeAmount: integer('charge_amount'), /** * 关联的预约 ID(若本次充电由预约触发) * StartTransaction.req.reservationId(optional) */ reservationId: integer('reservation_id'), createdAt: timestamp('created_at', { withTimezone: true }) .notNull() .defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }) .notNull() .defaultNow() .$onUpdate(() => new Date()), }, (table) => ({ chargePointIdIdx: index('idx_transaction_charge_point_id').on( table.chargePointId ), connectorIdIdx: index('idx_transaction_connector_id').on( table.connectorId ), idTagIdx: index('idx_transaction_id_tag').on(table.idTag), startTimestampIdx: index('idx_transaction_start_timestamp').on( table.startTimestamp ), }) ) // --------------------------------------------------------------------------- // 计量值 / Meter Value // OCPP 1.6-J Section 4.7 MeterValues;也内嵌于 StartTransaction / StopTransaction // --------------------------------------------------------------------------- /** SampledValue 类型定义(对应 OCPP 1.6-J Section 7.17) */ export type SampledValue = { /** 测量值(字符串形式) */ value: string /** * 读数上下文 — ReadingContext * Interruption.Begin / Interruption.End / Sample.Clock / * Sample.Periodic / Transaction.Begin / Transaction.End / Trigger / Other */ context?: | 'Interruption.Begin' | 'Interruption.End' | 'Sample.Clock' | 'Sample.Periodic' | 'Transaction.Begin' | 'Transaction.End' | 'Trigger' | 'Other' /** 数据格式:Raw(原始数值)/ SignedData(签名数据) */ format?: 'Raw' | 'SignedData' /** * 测量量 — Measurand(常用值) * Energy.Active.Import.Register / Power.Active.Import / * Current.Import / Voltage / SoC / Temperature 等 */ measurand?: string /** 相位信息(三相系统):L1 / L2 / L3 / N / L1-N / L2-N / L3-N / L1-L2 / L2-L3 / L3-L1 */ phase?: string /** 测量位置:Cable / EV / Inlet / Outlet / Body */ location?: string /** 单位:Wh / kWh / varh / kvarh / W / kW / VA / kVA / var / kvar / A / V / K / Celcius / Fahrenheit / Percent */ unit?: string } export const meterValue = pgTable( 'meter_value', { id: varchar('id').primaryKey(), /** * 关联的充电事务(可 null,如充电桩空闲时上报的周期性计量值) * MeterValues.req.transactionId(optional) */ transactionId: integer('transaction_id').references(() => transaction.id, { onDelete: 'set null', }), connectorId: varchar('connector_id') .notNull() .references(() => connector.id, { onDelete: 'cascade' }), chargePointId: varchar('charge_point_id') .notNull() .references(() => chargePoint.id, { onDelete: 'cascade' }), /** OCPP connectorId(冗余存储) */ connectorNumber: integer('connector_number').notNull(), /** * 充电桩上报的计量时间戳(UTC) * MeterValues.req.meterValue[].timestamp */ timestamp: timestamp('timestamp', { withTimezone: true }).notNull(), /** * 采样值数组(JSONB) * MeterValues.req.meterValue[].sampledValue[] * 保持 JSONB 格式以支持 measurand 类型不固定的场景 */ sampledValues: jsonb('sampled_values') .notNull() .$type(), /** CSMS 收到消息的时间 */ receivedAt: timestamp('received_at', { withTimezone: true }) .notNull() .defaultNow(), }, (table) => ({ transactionIdIdx: index('idx_meter_value_transaction_id').on( table.transactionId ), connectorIdIdx: index('idx_meter_value_connector_id').on( table.connectorId ), timestampIdx: index('idx_meter_value_timestamp').on(table.timestamp), }) ) // --------------------------------------------------------------------------- // 预约 / Reservation // OCPP 1.6-J Section 3.11 ReserveNow / Section 3.2 CancelReservation // --------------------------------------------------------------------------- export const reservation = pgTable( 'reservation', { /** * OCPP reservationId(整型,由 CSMS 分配) * ReserveNow.req.reservationId */ id: integer('id').primaryKey(), chargePointId: varchar('charge_point_id') .notNull() .references(() => chargePoint.id, { onDelete: 'cascade' }), /** * 关联的内部连接器记录(可 null:connectorId=0 表示任意可用插口) */ connectorId: varchar('connector_id').references(() => connector.id, { onDelete: 'set null', }), /** * OCPP connectorId(0 = 整桩任意插口,≥1 = 指定插口) * ReserveNow.req.connectorId */ connectorNumber: integer('connector_number').notNull(), /** * 预约到期时间(UTC) * ReserveNow.req.expiryDate */ expiryDate: timestamp('expiry_date', { withTimezone: true }).notNull(), /** * 预约使用的 idTag,CiString20Type * ReserveNow.req.idTag */ idTag: varchar('id_tag', { length: 20 }).notNull(), /** * 可选的父标签(用于允许同组任意 idTag 使用本预约) * ReserveNow.req.parentIdTag */ parentIdTag: varchar('parent_id_tag', { length: 20 }), /** * 预约状态(扩展字段,非 OCPP 原生) * Active = 有效中,Cancelled = 已取消,Expired = 已过期,Used = 已使用 */ status: varchar('status', { enum: ['Active', 'Cancelled', 'Expired', 'Used'], }) .notNull() .default('Active'), createdAt: timestamp('created_at', { withTimezone: true }) .notNull() .defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }) .notNull() .defaultNow() .$onUpdate(() => new Date()), }, (table) => ({ chargePointIdIdx: index('idx_reservation_charge_point_id').on( table.chargePointId ), statusIdx: index('idx_reservation_status').on(table.status), expiryDateIdx: index('idx_reservation_expiry_date').on(table.expiryDate), }) ) // --------------------------------------------------------------------------- // 本地授权列表 / Local Auth List // OCPP 1.6-J Section 3.9 SendLocalList / Section 3.8 GetLocalListVersion // --------------------------------------------------------------------------- /** * 充电桩本地授权列表缓存 * * 充电桩断网时依赖本地列表进行离线授权。CSMS 通过 SendLocalList 将列表 * 推送至充电桩,每次推送携带 listVersion(单调递增整数)。 * CSMS 侧同步维护此表,以便在需要时重新推送或审计。 */ export const localAuthList = pgTable( 'local_auth_list', { id: varchar('id').primaryKey(), chargePointId: varchar('charge_point_id') .notNull() .references(() => chargePoint.id, { onDelete: 'cascade' }), /** * 列表版本号(单调递增) * SendLocalList.req.listVersion */ listVersion: integer('list_version').notNull(), /** idTag,CiString20Type */ idTag: varchar('id_tag', { length: 20 }).notNull(), /** 可选父标签 */ parentIdTag: varchar('parent_id_tag', { length: 20 }), /** 本地授权状态 — AuthorizationStatus */ idTagStatus: varchar('id_tag_status', { enum: ['Accepted', 'Blocked', 'Expired', 'Invalid', 'ConcurrentTx'], }).notNull(), /** 过期时间(UTC,可选) */ expiryDate: timestamp('expiry_date', { withTimezone: true }), updatedAt: timestamp('updated_at', { withTimezone: true }) .notNull() .defaultNow() .$onUpdate(() => new Date()), }, (table) => ({ /** 同一充电桩内同一 idTag 唯一 */ chargePointIdTagUniq: uniqueIndex( 'idx_local_auth_list_charge_point_id_tag' ).on(table.chargePointId, table.idTag), }) ) // --------------------------------------------------------------------------- // 充电配置文件 / Charging Profile // OCPP 1.6-J Section 3.12 SetChargingProfile(Smart Charging Profile) // --------------------------------------------------------------------------- /** * ChargingSchedulePeriod 类型(对应 OCPP 1.6-J Section 7.8) * 描述某一时间段内的充电限制 */ export type ChargingSchedulePeriod = { /** 距 chargingSchedule.startSchedule 的偏移秒数 */ startPeriod: number /** 最大充电限制(单位由 chargingRateUnit 决定:W 或 A) */ limit: number /** 可选:允许使用的相数(1 或 3) */ numberPhases?: number } /** * ChargingSchedule 类型(对应 OCPP 1.6-J Section 7.9) */ export type ChargingSchedule = { /** 总时长(秒,可选)*/ duration?: number /** 计划开始时间(UTC ISO 8601,可选,Absolute 类型时使用) */ startSchedule?: string /** 限制单位:W(瓦特)或 A(安培) */ chargingRateUnit: 'W' | 'A' /** 各时间段的限制列表(按 startPeriod 升序排列) */ chargingSchedulePeriod: ChargingSchedulePeriod[] /** 最小充电速率(W / A,可选) */ minChargingRate?: number } export const chargingProfile = pgTable( 'charging_profile', { /** * OCPP chargingProfileId(整型,由 CSMS 分配) * SetChargingProfile.req.csChargingProfiles.chargingProfileId */ id: integer('id').primaryKey(), chargePointId: varchar('charge_point_id') .notNull() .references(() => chargePoint.id, { onDelete: 'cascade' }), /** * 关联的内部连接器(0 表示整桩级别的配置文件,对应 connectorId=0) */ connectorId: varchar('connector_id').references(() => connector.id, { onDelete: 'cascade', }), /** OCPP connectorId(0 = 充电桩级别,≥1 = 指定插口) */ connectorNumber: integer('connector_number').notNull(), /** * 关联的事务 ID(仅 TxProfile 类型时有效) * SetChargingProfile.req.csChargingProfiles.transactionId */ transactionId: integer('transaction_id').references(() => transaction.id, { onDelete: 'set null', }), /** * 优先级堆叠层级(数值越高,优先级越高) * SetChargingProfile.req.csChargingProfiles.stackLevel */ stackLevel: integer('stack_level').notNull(), /** * 配置文件用途 — ChargingProfilePurposeType * ChargePointMaxProfile:限制整桩最大功率 * TxDefaultProfile:默认事务充电功率 * TxProfile:绑定到特定事务的充电功率 */ chargingProfilePurpose: varchar('charging_profile_purpose', { enum: ['ChargePointMaxProfile', 'TxDefaultProfile', 'TxProfile'], }).notNull(), /** * 配置文件类型 — ChargingProfileKindType * Absolute:从指定时间点开始 * Recurring:周期性重复(Daily / Weekly) * Relative:相对于事务开始时间 */ chargingProfileKind: varchar('charging_profile_kind', { enum: ['Absolute', 'Recurring', 'Relative'], }).notNull(), /** * 周期类型(仅 Recurring 类型时有效) * Daily / Weekly */ recurrencyKind: varchar('recurrency_kind', { enum: ['Daily', 'Weekly'], }), /** 配置文件生效时间(UTC,可选) */ validFrom: timestamp('valid_from', { withTimezone: true }), /** 配置文件失效时间(UTC,可选) */ validTo: timestamp('valid_to', { withTimezone: true }), /** * 充电计划(JSONB) * SetChargingProfile.req.csChargingProfiles.chargingSchedule */ chargingSchedule: jsonb('charging_schedule') .notNull() .$type(), createdAt: timestamp('created_at', { withTimezone: true }) .notNull() .defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }) .notNull() .defaultNow() .$onUpdate(() => new Date()), }, (table) => ({ chargePointIdIdx: index('idx_charging_profile_charge_point_id').on( table.chargePointId ), connectorIdIdx: index('idx_charging_profile_connector_id').on( table.connectorId ), purposeStackIdx: index('idx_charging_profile_purpose_stack').on( table.connectorNumber, table.chargingProfilePurpose, table.stackLevel ), }) )