Skip to content

Formulas #136

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ JSON.
- [Documentation](#documentation)
- [Alternatives](#alternatives)
- [BUGS](#bugs)
- [PDF/A](#pdfa)
- [PDF/A](#pdfa)
- [Reporting Bugs](#reporting-bugs)
- [Copyright](#copyright)
- [Disclaimer](#disclaimer)
Expand Down Expand Up @@ -58,9 +58,9 @@ Case does not matter, when you specify a format.

There are several ways to use the software:

* a [commandline tool `e-invoice-eu`](apps/cli/README.md)
* a [web service with a RESTful API](apps/server/README.md)
* a [JavaScript/TypeScript library](packages/core/README.md)
- a [commandline tool `e-invoice-eu`](apps/cli/README.md)
- a [web service with a RESTful API](apps/server/README.md)
- a [JavaScript/TypeScript library](packages/core/README.md)

The JavaScript/TypeScript library works both on the commandline and in the
browser. The only limitation of the browser version is that it cannot generate
Expand Down
7 changes: 5 additions & 2 deletions apps/cli/src/commands/invoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
InvoiceServiceOptions,
MappingService,
} from '@e-invoice-eu/core';
import { Mapping } from '@e-invoice-eu/core';
import { Textdomain } from '@esgettext/runtime';
import { accessSync, statSync } from 'fs';
import * as fs from 'fs/promises';
Expand Down Expand Up @@ -305,7 +306,9 @@ export class Invoice implements Command {
} else if (typeof configOptions.mapping !== 'undefined') {
if (typeof configOptions.spreadsheet == 'undefined') {
throw new Error(
gtx._("The option '--spreadsheet' is mandatory if a mapping is specified!"),
gtx._(
"The option '--spreadsheet' is mandatory if a mapping is specified!",
),
);
}

Expand All @@ -314,7 +317,7 @@ export class Invoice implements Command {
'utf-8',
);

const mapping = yaml.load(mappingYaml);
const mapping = yaml.load(mappingYaml) as Mapping;

const mappingService = new MappingService(console);
invoiceData = mappingService.transform(
Expand Down
138 changes: 138 additions & 0 deletions apps/cli/src/commands/migrate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import * as fs from 'fs/promises';
import yargs from 'yargs';

import { coerceOptions } from '../optspec';
import { Package } from '../package';
import { safeStdoutWrite } from '../safe-stdout-write';
import { Migrate } from './migrate';

jest.mock('../optspec');
jest.mock('../package');

jest.mock('fs/promises');
const mockedFs = fs as jest.Mocked<typeof fs>;

jest.mock('../safe-stdout-write', () => ({
safeStdoutWrite: jest.fn().mockResolvedValue(undefined),
}));

const mockedData = 'migrated data';

jest.mock('@e-invoice-eu/core', () => ({
MappingService: jest.fn().mockImplementation(() => ({
migrate: jest.fn().mockReturnValue(mockedData),
})),
FormatFactoryService: jest.fn(),
}));

describe('Transform Command', () => {
let migrate: Migrate;

beforeEach(() => {
migrate = new Migrate();
jest.clearAllMocks();
});

test('description() should return a valid description', () => {
expect(migrate.description()).toBe('Migrate files.');
});

test('aliases() should return an empty array', () => {
expect(migrate.aliases()).toEqual([]);
});

test('build() should add expected options to yargs', () => {
const mockArgv = yargs([]);
const optionsSpy = jest.spyOn(mockArgv, 'options');

migrate.build(mockArgv);

expect(optionsSpy).toHaveBeenCalledWith({
mapping: expect.objectContaining({
group: 'Input file location',
alias: ['m'],
type: 'string',
demandOption: true,
describe: 'the mapping file',
}),
output: expect.objectContaining({
group: 'Output file location',
alias: ['o'],
type: 'string',
demandOption: false,
describe: 'the output file; standard output if `-`',
}),
});
});

test('run() should return 1 if coerceOptions fails', async () => {
(coerceOptions as jest.Mock).mockReturnValue(false);

const result = await migrate.run({} as yargs.Arguments);

expect(result).toBe(1);
});

test('run() should call doRun and return 0 on success', async () => {
(coerceOptions as jest.Mock).mockReturnValue(true);
const doRunSpy = jest
.spyOn(migrate as any, 'doRun')
.mockResolvedValue(undefined);

const result = await migrate.run({} as yargs.Arguments);

expect(doRunSpy).toHaveBeenCalled();
expect(result).toBe(0);
});

test('run() should return 1 and log an error if doRun throws', async () => {
(coerceOptions as jest.Mock).mockReturnValue(true);
const error = new Error('test error');
jest.spyOn(migrate as any, 'doRun').mockRejectedValue(error);

const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();

(Package.getName as jest.Mock).mockReturnValue('e-invoice-eu-cli');

const result = await migrate.run({} as yargs.Arguments);

expect(consoleErrorSpy).toHaveBeenCalledWith(
'e-invoice-eu-cli: Error: test error',
);
expect(result).toBe(1);

consoleErrorSpy.mockRestore();
});

it('should write to a file when output is specified', async () => {
const argv = {
mapping: 'mapping.yaml',
output: 'output.yaml',
} as unknown as yargs.Arguments;

mockedFs.readFile.mockResolvedValueOnce('migrated data');

await migrate.run(argv);

expect(mockedFs.writeFile).toHaveBeenCalledWith(
'output.yaml',
`${mockedData}\n`,
'utf-8',
);
});

it('should write to stdout when output is not specified', async () => {
const argv = {
data: 'data.xlsx',
mapping: 'mapping.json',
} as unknown as yargs.Arguments;

mockedFs.readFile.mockResolvedValueOnce(Buffer.from('spreadsheet data'));
mockedFs.readFile.mockResolvedValueOnce('mapping data');

await migrate.run(argv);

expect(safeStdoutWrite).toHaveBeenCalledTimes(1);
expect(safeStdoutWrite).toHaveBeenCalledWith(mockedData + '\n');
});
});
91 changes: 91 additions & 0 deletions apps/cli/src/commands/migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { Mapping, MappingService } from '@e-invoice-eu/core';
import { Textdomain } from '@esgettext/runtime';
import * as fs from 'fs/promises';
import * as yaml from 'js-yaml';
import yargs, { InferredOptionTypes } from 'yargs';

import { Command } from '../command';
import { coerceOptions, OptSpec } from '../optspec';
import { Package } from '../package';
import { safeStdoutWrite } from '../safe-stdout-write';

const gtx = Textdomain.getInstance('e-invoice-eu-cli');

const options: {
mapping: OptSpec;
output?: OptSpec;
} = {
mapping: {
group: gtx._('Input file location'),
alias: ['m'],
type: 'string',
demandOption: true,
describe: gtx._('the mapping file'),
},
output: {
group: gtx._('Output file location'),
alias: ['o'],
type: 'string',
demandOption: false,
describe: gtx._('the output file; standard output if `-`'),
},
};

export type ConfigOptions = InferredOptionTypes<typeof options>;

export class Migrate implements Command {
description(): string {
return gtx._('Migrate files.');
}

aliases(): Array<string> {
return [];
}

build(argv: yargs.Argv): yargs.Argv<object> {
return argv.options(options);
}

private async doRun(configOptions: ConfigOptions) {
const yamlData = await fs.readFile(
configOptions.mapping as string,
'utf-8',
);
const mapping = yaml.load(yamlData) as Mapping;

const mappingService = new MappingService(console);

const output = yaml.dump(mappingService.migrate(mapping));

if (
typeof configOptions.output === 'undefined' ||
configOptions.output === '-'
) {
safeStdoutWrite(output);
} else {
await fs.writeFile(configOptions.output as string, output, 'utf-8');
}
}

public async run(argv: yargs.Arguments): Promise<number> {
const configOptions = argv as unknown as ConfigOptions;

if (!coerceOptions(argv, options)) {
return 1;
}

try {
await this.doRun(configOptions);
return 0;
} catch (e) {
console.error(
gtx._x('{programName}: {error}', {
programName: Package.getName(),
error: e,
}),
);

return 1;
}
}
}
3 changes: 2 additions & 1 deletion apps/cli/src/commands/transform.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { MappingService } from '@e-invoice-eu/core';
import { Mapping } from '@e-invoice-eu/core';
import { Textdomain } from '@esgettext/runtime';
import * as fs from 'fs/promises';
import * as yaml from 'js-yaml';
Expand Down Expand Up @@ -60,7 +61,7 @@ export class Transform implements Command {
configOptions.mapping as string,
'utf-8',
);
const mapping = yaml.load(yamlData);
const mapping = yaml.load(yamlData) as Mapping;

const mappingService = new MappingService(console);

Expand Down
4 changes: 3 additions & 1 deletion apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import { Format } from './commands/format';
import { Invoice } from './commands/invoice';
import { Schema } from './commands/schema';
import { Transform } from './commands/transform';
import { Migrate } from './commands/migrate';
import { Package } from './package';

const commandNames = ['invoice', 'transform', 'format', 'schema'];
const commandNames = ['invoice', 'transform', 'migrate', 'format', 'schema'];

const gtx = Textdomain.getInstance('e-einvoice-eu-cli');
v.setGlobalConfig({ lang: Textdomain.locale });
Expand All @@ -28,6 +29,7 @@ gtx
const commands: { [key: string]: Command } = {
invoice: new Invoice(),
transform: new Transform(),
migrate: new Migrate(),
format: new Format(),
schema: new Schema(),
};
Expand Down
59 changes: 57 additions & 2 deletions apps/server/src/mapping/mapping.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ export class MappingController {
@Post('transform/:format')
@ApiConsumes('multipart/form-data')
@ApiBody({
description: 'The spreadsheet to be transformed.',
description:
'Transform a spreadsheet with a mapping into the internal invoice format.',
required: true,
schema: {
type: 'object',
Expand All @@ -35,7 +36,7 @@ export class MappingController {
type: 'string',
format: 'binary',
nullable: true,
description: 'The spreadsheet to be transformed.'
description: 'The spreadsheet to be transformed.',
},
data: {
type: 'string',
Expand Down Expand Up @@ -122,4 +123,58 @@ export class MappingController {
}
}
}

@Post('migrate')
@ApiConsumes('multipart/form-data')
@ApiBody({
description: 'Migrate a mapping to the latest version.',
required: true,
schema: {
type: 'object',
properties: {
mapping: {
type: 'string',
format: 'binary',
nullable: false,
description: 'The mapping file in YAML or JSON format.',
},
},
},
})
@ApiResponse({
status: 201,
description: 'Migration successful. The output is in JSON format.',
})
@ApiResponse({
status: 400,
description: 'Bad request with error details',
})
@UseInterceptors(FileFieldsInterceptor([{ name: 'mapping', maxCount: 1 }]))
migrate(
@UploadedFiles()
files: {
mapping: Express.Multer.File[];
},
): string {
const mappingFile = files.mapping?.[0];
if (!mappingFile) {
throw new BadRequestException('No mapping file uploaded');
}

try {
return JSON.stringify(
this.mappingService.migrate(mappingFile.buffer.toString()),
);
} catch (error) {
if (error instanceof ValidationError) {
throw new BadRequestException({
message: 'Migration failed.',
details: error,
});
} else {
this.logger.error(`unknown error: ${error.message}\n${error.stack}`);
throw new InternalServerErrorException();
}
}
}
}
Loading