797b20979d
承运商 HTML 按收货市下发;textarea 回车在 C 端转成换行。含门店去掉分享按钮与小程序企微客服。 Co-authored-by: Cursor <cursoragent@cursor.com>
531 lines
19 KiB
TypeScript
531 lines
19 KiB
TypeScript
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import {
|
|
FulfillmentProviderStatus,
|
|
FulfillmentProviderType,
|
|
LogisticsSettlementMethod,
|
|
Prisma,
|
|
} from '@prisma/client';
|
|
import {
|
|
DEFAULT_XFX_LOGISTICS_PRICING,
|
|
isXfxProviderCode,
|
|
sanitizeDeliveryHintHtml,
|
|
type LogisticsPricingRuleDto,
|
|
type XiaofeixiaProviderConfig,
|
|
type XiaofeixiaProviderConfigPublic,
|
|
} from '@dukang/shared-types';
|
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
|
import type { XiaofeixiaConfig, XiaofeixiaSignType } from '../../integrations/courier/courier.config';
|
|
|
|
export type CreateFulfillmentProviderInput = {
|
|
code: string;
|
|
name: string;
|
|
type: FulfillmentProviderType;
|
|
status?: FulfillmentProviderStatus;
|
|
configJson?: string;
|
|
capabilitiesJson?: string;
|
|
xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>;
|
|
bankAccountName?: string | null;
|
|
bankName?: string | null;
|
|
bankBranch?: string | null;
|
|
bankAccountNo?: string | null;
|
|
settlementMethod?: LogisticsSettlementMethod | string;
|
|
pricingRules?: LogisticsPricingRuleDto | null;
|
|
deliveryHintHtml?: string | null;
|
|
};
|
|
|
|
export type UpdateFulfillmentProviderInput = Partial<CreateFulfillmentProviderInput>;
|
|
|
|
type Capabilities = {
|
|
createShipment?: boolean;
|
|
getTrack?: boolean;
|
|
callback?: boolean;
|
|
cancel?: boolean;
|
|
};
|
|
|
|
const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do';
|
|
|
|
@Injectable()
|
|
export class FulfillmentProviderService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
async listActiveApiProviders() {
|
|
const rows = await this.prisma.fulfillmentProvider.findMany({
|
|
where: { status: 'ACTIVE', type: 'API' },
|
|
orderBy: { name: 'asc' },
|
|
});
|
|
return rows.map((row) => this.toDto(row));
|
|
}
|
|
|
|
async listAll() {
|
|
const rows = await this.prisma.fulfillmentProvider.findMany({
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
return rows.map((row) => this.toDto(row));
|
|
}
|
|
|
|
async getById(id: bigint) {
|
|
const row = await this.prisma.fulfillmentProvider.findUnique({ where: { id } });
|
|
if (!row) throw new NotFoundException('仓配承运商不存在');
|
|
return this.toDto(row);
|
|
}
|
|
|
|
/** 供推单使用:解析完整小飞侠凭证(含 apiKey) */
|
|
async resolveXiaofeixiaConfig(providerId: bigint): Promise<XiaofeixiaConfig> {
|
|
const row = await this.prisma.fulfillmentProvider.findUnique({ where: { id: providerId } });
|
|
if (!row) throw new NotFoundException('仓配承运商不存在');
|
|
if (!isXfxProviderCode(row.code)) {
|
|
throw new BadRequestException('该承运商不是小飞侠');
|
|
}
|
|
const cfg = this.parseXiaofeixiaConfig(row.configJson);
|
|
if (!cfg?.mchId || !cfg?.apiKey || !cfg?.apiUrl) {
|
|
throw new BadRequestException('小飞侠仓配配置不完整,请在仓配管理中填写 API 地址、商户号与 API Key');
|
|
}
|
|
return {
|
|
apiUrl: cfg.apiUrl,
|
|
mchId: cfg.mchId,
|
|
apiKey: cfg.apiKey,
|
|
signType: this.resolveSignType(cfg.signType),
|
|
appId: cfg.appId || undefined,
|
|
};
|
|
}
|
|
|
|
/** 取第一个启用的小飞侠承运商配置(联调/兼容) */
|
|
async resolveDefaultXiaofeixiaConfig(): Promise<XiaofeixiaConfig | null> {
|
|
const row = await this.prisma.fulfillmentProvider.findFirst({
|
|
where: {
|
|
status: 'ACTIVE',
|
|
type: 'API',
|
|
code: { in: ['XFX', 'XIAOFEIXIA'] },
|
|
},
|
|
orderBy: { updatedAt: 'desc' },
|
|
});
|
|
if (!row?.configJson) return null;
|
|
try {
|
|
return await this.resolveXiaofeixiaConfig(row.id);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async create(input: CreateFulfillmentProviderInput) {
|
|
const code = input.code.trim().toUpperCase();
|
|
if (!/^[A-Z0-9_]+$/.test(code)) {
|
|
throw new BadRequestException('承运商编码仅支持大写字母、数字和下划线');
|
|
}
|
|
const existing = await this.prisma.fulfillmentProvider.findUnique({ where: { code } });
|
|
if (existing) throw new BadRequestException('承运商编码已存在');
|
|
|
|
const configJson = this.resolveConfigJsonForWrite(code, null, input);
|
|
if (isXfxProviderCode(code) && input.type === 'API') {
|
|
this.assertXiaofeixiaConfigComplete(configJson, true);
|
|
}
|
|
|
|
const pricingRulesJson = this.resolvePricingRulesJson(
|
|
code,
|
|
input.pricingRules,
|
|
isXfxProviderCode(code) ? DEFAULT_XFX_LOGISTICS_PRICING : null,
|
|
);
|
|
const settlementMethod = this.parseSettlementMethod(input.settlementMethod) ?? 'PREPAID';
|
|
|
|
const row = await this.prisma.fulfillmentProvider.create({
|
|
data: {
|
|
code,
|
|
name: input.name.trim(),
|
|
type: input.type,
|
|
status: input.status ?? 'ACTIVE',
|
|
configJson,
|
|
capabilitiesJson:
|
|
input.capabilitiesJson?.trim() ||
|
|
(isXfxProviderCode(code)
|
|
? JSON.stringify({
|
|
createShipment: true,
|
|
getTrack: true,
|
|
callback: true,
|
|
cancel: true,
|
|
})
|
|
: null),
|
|
bankAccountName: this.normOptional(input.bankAccountName),
|
|
bankName: this.normOptional(input.bankName),
|
|
bankBranch: this.normOptional(input.bankBranch),
|
|
bankAccountNo: this.normOptional(input.bankAccountNo),
|
|
settlementMethod,
|
|
pricingRulesJson,
|
|
deliveryHintHtml: sanitizeDeliveryHintHtml(input.deliveryHintHtml),
|
|
},
|
|
});
|
|
return this.toDto(row);
|
|
}
|
|
|
|
async update(id: bigint, input: UpdateFulfillmentProviderInput) {
|
|
const current = await this.prisma.fulfillmentProvider.findUnique({ where: { id } });
|
|
if (!current) throw new NotFoundException('仓配承运商不存在');
|
|
|
|
const code = current.code;
|
|
const configJson =
|
|
input.xiaofeixiaConfig !== undefined || input.configJson !== undefined
|
|
? this.resolveConfigJsonForWrite(code, current.configJson, input)
|
|
: undefined;
|
|
|
|
if (configJson !== undefined && isXfxProviderCode(code) && (input.type ?? current.type) === 'API') {
|
|
this.assertXiaofeixiaConfigComplete(configJson, false);
|
|
}
|
|
|
|
const pricingRulesJson =
|
|
input.pricingRules !== undefined
|
|
? this.resolvePricingRulesJson(code, input.pricingRules, null)
|
|
: undefined;
|
|
|
|
const row = await this.prisma.fulfillmentProvider.update({
|
|
where: { id },
|
|
data: {
|
|
...(input.name !== undefined ? { name: input.name.trim() } : {}),
|
|
...(input.type !== undefined ? { type: input.type } : {}),
|
|
...(input.status !== undefined ? { status: input.status } : {}),
|
|
...(configJson !== undefined ? { configJson } : {}),
|
|
...(input.capabilitiesJson !== undefined
|
|
? { capabilitiesJson: input.capabilitiesJson?.trim() || null }
|
|
: {}),
|
|
...(input.bankAccountName !== undefined
|
|
? { bankAccountName: this.normOptional(input.bankAccountName) }
|
|
: {}),
|
|
...(input.bankName !== undefined ? { bankName: this.normOptional(input.bankName) } : {}),
|
|
...(input.bankBranch !== undefined ? { bankBranch: this.normOptional(input.bankBranch) } : {}),
|
|
...(input.bankAccountNo !== undefined
|
|
? { bankAccountNo: this.normOptional(input.bankAccountNo) }
|
|
: {}),
|
|
...(input.settlementMethod !== undefined
|
|
? { settlementMethod: this.parseSettlementMethod(input.settlementMethod)! }
|
|
: {}),
|
|
...(pricingRulesJson !== undefined ? { pricingRulesJson } : {}),
|
|
...(input.deliveryHintHtml !== undefined
|
|
? { deliveryHintHtml: sanitizeDeliveryHintHtml(input.deliveryHintHtml) }
|
|
: {}),
|
|
},
|
|
});
|
|
return this.toDto(row);
|
|
}
|
|
|
|
async remove(id: bigint) {
|
|
const current = await this.prisma.fulfillmentProvider.findUnique({ where: { id } });
|
|
if (!current) throw new NotFoundException('仓配承运商不存在');
|
|
|
|
const [billCount, ledgerCount] = await Promise.all([
|
|
this.prisma.logisticsBill.count({ where: { fulfillmentProviderId: id } }),
|
|
this.prisma.logisticsPrepaidLedger.count({ where: { fulfillmentProviderId: id } }),
|
|
]);
|
|
if (billCount > 0) {
|
|
throw new BadRequestException(`该承运商已有 ${billCount} 笔物流对账单,不能删除`);
|
|
}
|
|
if (ledgerCount > 0) {
|
|
throw new BadRequestException('该承运商已有充值/扣款流水,不能删除');
|
|
}
|
|
|
|
await this.prisma.$transaction(async (tx) => {
|
|
await tx.cityWarehouse.updateMany({
|
|
where: { fulfillmentProviderId: id },
|
|
data: { fulfillmentProviderId: null, fulfillmentMode: 'MANUAL' },
|
|
});
|
|
await tx.orderDelivery.updateMany({
|
|
where: { fulfillmentProviderId: id },
|
|
data: { fulfillmentProviderId: null },
|
|
});
|
|
await tx.fulfillmentProvider.delete({ where: { id } });
|
|
});
|
|
return { ok: true, id: id.toString() };
|
|
}
|
|
|
|
/** 充值(结算模块可复用) */
|
|
async rechargePrepaid(providerId: bigint, amount: number, remark?: string) {
|
|
if (!(amount > 0)) throw new BadRequestException('充值金额须大于 0');
|
|
const rounded = Math.round(amount * 100) / 100;
|
|
|
|
const result = await this.prisma.$transaction(async (tx) => {
|
|
const row = await tx.fulfillmentProvider.findUnique({ where: { id: providerId } });
|
|
if (!row) throw new NotFoundException('仓配承运商不存在');
|
|
const balanceAfter = Math.round((Number(row.prepaidBalance) + rounded) * 100) / 100;
|
|
const updated = await tx.fulfillmentProvider.update({
|
|
where: { id: providerId },
|
|
data: { prepaidBalance: balanceAfter },
|
|
});
|
|
const ledger = await tx.logisticsPrepaidLedger.create({
|
|
data: {
|
|
fulfillmentProviderId: providerId,
|
|
type: 'RECHARGE',
|
|
amount: rounded,
|
|
balanceAfter,
|
|
remark: remark?.trim() || '充值',
|
|
},
|
|
});
|
|
return { provider: updated, ledger };
|
|
});
|
|
|
|
return serializeBigInt({
|
|
provider: this.toDto(result.provider),
|
|
ledger: result.ledger,
|
|
});
|
|
}
|
|
|
|
/** 账单扣减充值余额;余额不足返回 false */
|
|
async deductPrepaid(
|
|
providerId: bigint,
|
|
amount: number,
|
|
logisticsBillId: bigint,
|
|
tx?: Prisma.TransactionClient,
|
|
): Promise<{ ok: true; balanceAfter: number } | { ok: false; balance: number }> {
|
|
const client = tx ?? this.prisma;
|
|
const rounded = Math.round(amount * 100) / 100;
|
|
const row = await client.fulfillmentProvider.findUnique({ where: { id: providerId } });
|
|
if (!row) throw new NotFoundException('仓配承运商不存在');
|
|
const balance = Number(row.prepaidBalance);
|
|
if (balance + 1e-9 < rounded) {
|
|
return { ok: false, balance };
|
|
}
|
|
const balanceAfter = Math.round((balance - rounded) * 100) / 100;
|
|
await client.fulfillmentProvider.update({
|
|
where: { id: providerId },
|
|
data: { prepaidBalance: balanceAfter },
|
|
});
|
|
await client.logisticsPrepaidLedger.create({
|
|
data: {
|
|
fulfillmentProviderId: providerId,
|
|
type: 'DEDUCT',
|
|
amount: rounded,
|
|
balanceAfter,
|
|
logisticsBillId,
|
|
remark: '物流月账单扣款',
|
|
},
|
|
});
|
|
return { ok: true, balanceAfter };
|
|
}
|
|
|
|
parseCapabilities(raw: string | null): Capabilities | null {
|
|
if (!raw) return null;
|
|
try {
|
|
return JSON.parse(raw) as Capabilities;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
parseXiaofeixiaConfig(raw: string | null): XiaofeixiaProviderConfig | null {
|
|
if (!raw) return null;
|
|
try {
|
|
const parsed = JSON.parse(raw) as Partial<XiaofeixiaProviderConfig>;
|
|
if (!parsed || typeof parsed !== 'object') return null;
|
|
return {
|
|
apiUrl: String(parsed.apiUrl ?? '').trim(),
|
|
mchId: String(parsed.mchId ?? '').trim(),
|
|
apiKey: String(parsed.apiKey ?? '').trim(),
|
|
signType: parsed.signType === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5',
|
|
appId: parsed.appId ? String(parsed.appId).trim() : undefined,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
parsePricingRules(raw: string | null): LogisticsPricingRuleDto | null {
|
|
if (!raw) return null;
|
|
try {
|
|
const parsed = JSON.parse(raw) as Partial<LogisticsPricingRuleDto>;
|
|
if (!parsed || typeof parsed !== 'object') return null;
|
|
const baseBottles = Number(parsed.baseBottles);
|
|
const baseFee = Number(parsed.baseFee);
|
|
const extraBottleFee = Number(parsed.extraBottleFee);
|
|
if (!(baseBottles > 0) || !(baseFee >= 0) || !(extraBottleFee >= 0)) return null;
|
|
const rule: LogisticsPricingRuleDto = { baseBottles, baseFee, extraBottleFee };
|
|
if (parsed.boxBottles != null && Number(parsed.boxBottles) > 0) {
|
|
rule.boxBottles = Number(parsed.boxBottles);
|
|
}
|
|
if (parsed.boxFee != null && Number(parsed.boxFee) >= 0) {
|
|
rule.boxFee = Number(parsed.boxFee);
|
|
}
|
|
return rule;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private resolvePricingRulesJson(
|
|
code: string,
|
|
input: LogisticsPricingRuleDto | null | undefined,
|
|
fallback: LogisticsPricingRuleDto | null,
|
|
): string | null {
|
|
if (input === null) return null;
|
|
const rule = input ?? fallback;
|
|
if (!rule) return isXfxProviderCode(code) ? JSON.stringify(DEFAULT_XFX_LOGISTICS_PRICING) : null;
|
|
if (!(rule.baseBottles > 0)) throw new BadRequestException('计价标准:起送瓶数须大于 0');
|
|
if (!(rule.baseFee >= 0)) throw new BadRequestException('计价标准:起送费用无效');
|
|
if (!(rule.extraBottleFee >= 0)) throw new BadRequestException('计价标准:加瓶费用无效');
|
|
return JSON.stringify({
|
|
baseBottles: Number(rule.baseBottles),
|
|
baseFee: Number(rule.baseFee),
|
|
extraBottleFee: Number(rule.extraBottleFee),
|
|
...(rule.boxBottles != null ? { boxBottles: Number(rule.boxBottles) } : {}),
|
|
...(rule.boxFee != null ? { boxFee: Number(rule.boxFee) } : {}),
|
|
});
|
|
}
|
|
|
|
private parseSettlementMethod(
|
|
raw?: LogisticsSettlementMethod | string | null,
|
|
): LogisticsSettlementMethod | null {
|
|
if (raw == null || raw === '') return null;
|
|
if (raw === 'PREPAID' || raw === 'MONTHLY_CREDIT') return raw;
|
|
throw new BadRequestException('结算方式仅支持 PREPAID / MONTHLY_CREDIT');
|
|
}
|
|
|
|
private normOptional(v?: string | null) {
|
|
if (v === undefined) return undefined;
|
|
if (v == null) return null;
|
|
const t = String(v).trim();
|
|
return t || null;
|
|
}
|
|
|
|
private resolveConfigJsonForWrite(
|
|
code: string,
|
|
existingRaw: string | null,
|
|
input: CreateFulfillmentProviderInput | UpdateFulfillmentProviderInput,
|
|
): string | null {
|
|
if (isXfxProviderCode(code) && input.xiaofeixiaConfig) {
|
|
const existing = this.parseXiaofeixiaConfig(existingRaw);
|
|
const next: XiaofeixiaProviderConfig = {
|
|
apiUrl: (input.xiaofeixiaConfig.apiUrl ?? existing?.apiUrl ?? DEFAULT_XFX_API_URL).trim(),
|
|
mchId: (input.xiaofeixiaConfig.mchId ?? existing?.mchId ?? '').trim(),
|
|
apiKey: (input.xiaofeixiaConfig.apiKey || existing?.apiKey || '').trim(),
|
|
signType:
|
|
input.xiaofeixiaConfig.signType === 'HMAC-SHA256'
|
|
? 'HMAC-SHA256'
|
|
: input.xiaofeixiaConfig.signType === 'MD5'
|
|
? 'MD5'
|
|
: existing?.signType ?? 'MD5',
|
|
appId: (input.xiaofeixiaConfig.appId ?? existing?.appId)?.trim() || undefined,
|
|
};
|
|
return JSON.stringify(next);
|
|
}
|
|
if (input.configJson !== undefined) {
|
|
return input.configJson?.trim() || null;
|
|
}
|
|
return existingRaw;
|
|
}
|
|
|
|
private assertXiaofeixiaConfigComplete(configJson: string | null, requireApiKey: boolean) {
|
|
const cfg = this.parseXiaofeixiaConfig(configJson);
|
|
if (!cfg?.apiUrl) throw new BadRequestException('请填写小飞侠 API 地址');
|
|
if (!cfg.mchId) throw new BadRequestException('请填写小飞侠商户号');
|
|
if (requireApiKey && !cfg.apiKey) throw new BadRequestException('请填写小飞侠 API Key');
|
|
if (!requireApiKey && !cfg.apiKey) throw new BadRequestException('小飞侠 API Key 缺失,请重新填写');
|
|
}
|
|
|
|
private toPublicXiaofeixiaConfig(raw: string | null): XiaofeixiaProviderConfigPublic | null {
|
|
const cfg = this.parseXiaofeixiaConfig(raw);
|
|
if (!cfg) return null;
|
|
return {
|
|
apiUrl: cfg.apiUrl || DEFAULT_XFX_API_URL,
|
|
mchId: cfg.mchId,
|
|
signType: cfg.signType === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5',
|
|
appId: cfg.appId,
|
|
hasApiKey: Boolean(cfg.apiKey),
|
|
};
|
|
}
|
|
|
|
private resolveSignType(raw?: string): XiaofeixiaSignType {
|
|
return raw?.toUpperCase() === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5';
|
|
}
|
|
|
|
private toDto(row: {
|
|
id: bigint;
|
|
code: string;
|
|
name: string;
|
|
type: string;
|
|
status: string;
|
|
configJson: string | null;
|
|
capabilitiesJson: string | null;
|
|
bankAccountName?: string | null;
|
|
bankName?: string | null;
|
|
bankBranch?: string | null;
|
|
bankAccountNo?: string | null;
|
|
settlementMethod?: string;
|
|
pricingRulesJson?: string | null;
|
|
prepaidBalance?: Prisma.Decimal | number;
|
|
deliveryHintHtml?: string | null;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}) {
|
|
return serializeBigInt({
|
|
id: row.id.toString(),
|
|
code: row.code,
|
|
name: row.name,
|
|
type: row.type,
|
|
status: row.status,
|
|
capabilities: this.parseCapabilities(row.capabilitiesJson),
|
|
hasConfig: Boolean(row.configJson),
|
|
xiaofeixiaConfig: isXfxProviderCode(row.code)
|
|
? this.toPublicXiaofeixiaConfig(row.configJson)
|
|
: null,
|
|
bankAccountName: row.bankAccountName ?? null,
|
|
bankName: row.bankName ?? null,
|
|
bankBranch: row.bankBranch ?? null,
|
|
bankAccountNo: row.bankAccountNo ?? null,
|
|
settlementMethod: row.settlementMethod ?? 'PREPAID',
|
|
pricingRules: this.parsePricingRules(row.pricingRulesJson ?? null),
|
|
prepaidBalance: Number(row.prepaidBalance ?? 0),
|
|
deliveryHintHtml: sanitizeDeliveryHintHtml(row.deliveryHintHtml ?? null),
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
});
|
|
}
|
|
|
|
async listLocalDeliveries(filter?: { cityCode?: string; cityName?: string }) {
|
|
const cityCode = filter?.cityCode?.trim() || '';
|
|
const cityName = filter?.cityName?.trim() || '';
|
|
const cities = await this.prisma.commonCity.findMany({
|
|
where: {
|
|
status: 'ACTIVE',
|
|
...(cityCode ? { code: cityCode } : {}),
|
|
...(cityName && !cityCode
|
|
? { name: { in: this.cityNameAliases(cityName) } }
|
|
: {}),
|
|
},
|
|
include: {
|
|
warehouses: {
|
|
where: { status: 'ACTIVE' },
|
|
include: { fulfillmentProvider: true },
|
|
orderBy: { createdAt: 'asc' },
|
|
},
|
|
},
|
|
orderBy: { name: 'asc' },
|
|
});
|
|
|
|
return cities.map((city) => {
|
|
const preferred =
|
|
city.warehouses.find((w) => w.fulfillmentMode === 'API_AUTO' && w.fulfillmentProviderId) ??
|
|
city.warehouses.find((w) => w.fulfillmentProviderId) ??
|
|
null;
|
|
const provider = preferred?.fulfillmentProvider ?? null;
|
|
return {
|
|
city: { id: city.id.toString(), code: city.code, name: city.name },
|
|
warehouse: preferred
|
|
? {
|
|
id: preferred.id.toString(),
|
|
name: preferred.name,
|
|
fulfillmentMode: preferred.fulfillmentMode,
|
|
}
|
|
: null,
|
|
provider: provider
|
|
? { id: provider.id.toString(), code: provider.code, name: provider.name }
|
|
: null,
|
|
hintHtml: sanitizeDeliveryHintHtml(provider?.deliveryHintHtml ?? null),
|
|
};
|
|
});
|
|
}
|
|
|
|
private cityNameAliases(name: string): string[] {
|
|
const raw = name.trim();
|
|
if (!raw) return [];
|
|
const noSuffix = raw.replace(/市$/, '');
|
|
const withSuffix = raw.endsWith('市') ? raw : `${raw}市`;
|
|
return Array.from(new Set([raw, noSuffix, withSuffix]));
|
|
}
|
|
}
|