feat(contracts,automation): add multi-group scopes, SMS macros and operator registry

- Allow scheduled operations to resolve multiple device groups.

- Add reusable SMS time and random-value macros.

- Add China operator PLMN metadata shared by API and UI.
This commit is contained in:
chick
2026-09-07 00:44:33 +08:00
parent 8b7df8cb65
commit 8406f14469
9 changed files with 276 additions and 3 deletions
@@ -46,6 +46,15 @@ describe('automation contracts', () => {
).toThrow(/instance/i);
});
it('parses Hub-compatible multi-group automation scopes', () => {
expect(
parseCreateScheduledTaskRequest({
...restartRequest,
targetSelector: { mode: 'groups', groupIds: ['field', 'office'] },
}).targetSelector,
).toEqual({ mode: 'groups', groupIds: ['field', 'office'] });
});
it('requires bounded recipients and content only for SMS schedules', () => {
expect(
parseCreateScheduledTaskRequest({
+5
View File
@@ -34,6 +34,7 @@ export type ScheduleTargetSelector =
| { readonly mode: 'fixed'; readonly instanceIds: readonly string[] }
| { readonly mode: 'tags'; readonly match: 'any' | 'all'; readonly tags: readonly string[] }
| { readonly mode: 'group'; readonly groupId: string }
| { readonly mode: 'groups'; readonly groupIds: readonly string[] }
| { readonly mode: 'all' };
/**
@@ -244,6 +245,10 @@ function targetSelector(value: unknown): ScheduleTargetSelector {
exactKeys(source, new Set(['mode', 'groupId']));
return { mode: 'group', groupId: string(source.groupId, 'groupId', 120) };
}
if (source.mode === 'groups') {
exactKeys(source, new Set(['mode', 'groupIds']));
return { mode: 'groups', groupIds: strings(source.groupIds, 'groupIds', 200) };
}
if (source.mode === 'all') {
exactKeys(source, new Set(['mode']));
return { mode: 'all' };
+1
View File
@@ -6,4 +6,5 @@ export * from './jobs.js';
export * from './operations.js';
export * from './organization.js';
export * from './notifications.js';
export * from './operators.js';
export const contractsWorkspaceReady = true;
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { lookupOperator, operatorLabel } from './operators.js';
describe('lookupOperator', () => {
it('resolves an assigned mainland PLMN', () => {
expect(lookupOperator('460', '00')?.name).toBe('中国移动');
expect(lookupOperator(460, 1)?.name).toBe('中国联通');
expect(lookupOperator('460', '15')?.name).toBe('中国广电');
expect(lookupOperator('460', '20')?.name).toBe('中国铁通');
});
it('pads a single-digit MNC the way the network reports it', () => {
expect(lookupOperator('460', '3')?.name).toBe('中国电信');
});
it('leaves foreign and unassigned codes unresolved', () => {
expect(lookupOperator('262', '01')).toBeNull();
expect(lookupOperator('460', '77')).toBeNull();
expect(lookupOperator(undefined, '01')).toBeNull();
expect(lookupOperator('', '')).toBeNull();
});
});
describe('operatorLabel', () => {
it('prefers the registry over the string the device reported', () => {
expect(operatorLabel({ mcc: '460', mnc: '00', operator: 'CHN-CMCC' })).toBe('中国移动');
});
it('falls back to the reported name, then the bare PLMN, then the caller default', () => {
expect(operatorLabel({ mcc: '262', mnc: '01', operator: 'Telekom' })).toBe('Telekom');
expect(operatorLabel({ mcc: '262', mnc: '01' })).toBe('262-01');
expect(operatorLabel({})).toBe('未知运营商');
expect(operatorLabel({ operator: ' ' })).toBe('未知运营商');
});
it('accepts a custom fallback for a table cell', () => {
expect(operatorLabel({ fallback: '运营商未知' })).toBe('运营商未知');
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* Mainland-China PLMN registry.
*
* Devices report MCC and MNC as bare numbers and the operator string they read off the SIM,
* which varies by firmware, language and roaming state. The Hub resolves the pair against this
* table instead of trusting the string, so the console shows one stable brand name everywhere.
*/
export interface ChinaOperator {
readonly mccMnc: string;
readonly name: string;
readonly nameEn: string;
readonly technology: string;
}
const MOBILE = '中国移动';
const UNICOM = '中国联通';
const TELECOM = '中国电信';
/** MNCs that belong to each brand, straight from the assigned mainland ranges. */
const BRANDS: readonly (readonly [string, string, string, readonly string[]])[] = [
[
MOBILE,
'China Mobile',
'GSM 900 / GSM 1800 / TD-SCDMA 1880 / TD-SCDMA 2010 / TD-LTE 1800/2300/2600',
['00', '02', '07', '08'],
],
[
UNICOM,
'China Unicom',
'GSM 900 / GSM 1800 / UMTS 2100 / TD-LTE 2300/2600 / FDD-LTE 1800/2100',
['01', '06', '09'],
],
[
TELECOM,
'China Telecom',
'CDMA2000 800 / CDMA2000 2100 / TD-LTE 2300/2600 / FDD-LTE 1800/2100 / EV-DO / eHRPD',
['03', '05', '11'],
],
[
'中国广电',
'China Broadnet',
'LTE 1800 / LTE 900 / TD-LTE 1900 / TD-LTE 2300 / 5G 700 / 5G 2500',
['15'],
],
['中国铁通', 'China Tietong', 'GSM-R', ['20']],
];
function buildRegistry(): ReadonlyMap<string, ChinaOperator> {
const entries = new Map<string, ChinaOperator>();
for (const [name, nameEn, technology, mncs] of BRANDS) {
for (const mnc of mncs)
entries.set(`460${mnc}`, { mccMnc: `460${mnc}`, name, nameEn, technology });
}
return entries;
}
const REGISTRY = buildRegistry();
/** MCC and MNC arrive as numbers or strings of varying width; MNC is always two digits. */
function digits(value: unknown, width: number): string {
const raw = typeof value === 'number' ? String(value) : typeof value === 'string' ? value : '';
const clean = raw.trim().replace(/\D/gu, '');
return clean ? clean.padStart(width, '0') : '';
}
export function lookupOperator(mcc: unknown, mnc: unknown): ChinaOperator | null {
const country = digits(mcc, 3);
const network = digits(mnc, 2);
if (!country || !network) return null;
return REGISTRY.get(`${country}${network}`) ?? null;
}
/**
* Best available operator name: the registry wins, then whatever the device reported, then the
* bare PLMN. Never returns an empty string so a table cell always reads as an operator.
*/
export function operatorLabel(input: {
readonly mcc?: unknown;
readonly mnc?: unknown;
readonly operator?: unknown;
readonly fallback?: string | undefined;
}): string {
const known = lookupOperator(input.mcc, input.mnc);
if (known) return known.name;
const reported = typeof input.operator === 'string' ? input.operator.trim() : '';
if (reported) return reported;
const country = digits(input.mcc, 3);
const network = digits(input.mnc, 2);
if (country && network) return `${country}-${network}`;
return input.fallback ?? '未知运营商';
}